Projets Python Bloc 1

Labyrinthe : Génération et résolution

Voir le code
						
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Fri Jul  2 20:54:38 2021

@author: Jason L*, SKun*
"""

from PIL import Image
import random


class Labyrinthe:
    
    def __init__(self, m:int, n:int):
        '''
        Parameters
        ----------
        m : int
            nombre de lignes (hors cases de portes ou murs)
        n : int
            nombre de colonnes (hors cases de portes ou murs)

        Génére une grille de taille (2m+1) x (2n+1) qui sera utilisée pour construire un labyrinthe.
        Entre deux cases "vides" se trouve soit un mur, soit une porte, qui occupe également une case. Au départ, il n'y a 
        pas de portes, seulement des cases vides et des murs. Les murs horizontaux ou verticaux n'ont de différent que le fait
        qu'ils séparent soit deux cases adjacentes verticalement, soit deux cases adjacentes horizontalement. Mais ils occupent
        tous les deux une case complète à l'instar d'une case occupable.
        
        les indices (i, j) de type (impair, impair) sont les seuls correspondant aux cases du labyrinthe
        les indices (i, j) de type (pair, impair) ou (impair, pair) correspondent aux emplacements des murs (ou des portes)
        les indices (i, j) de type (pair, pair) correspondent aux coins.
        
        exemple : labyrinthe (3, 4) -> de taille (7, 9):
        
          j  0 1 2 3 4 5 6 7 8
        i    
        0   B B B B B B B B B
        1   B 0 | 1 | 2 | 3 B
        2   B - + - + - + - B
        3   B 4 | 5 | 6 | 7 B
        4   B - + - + - + - B
        5   B 8 | 9 |10 |11 B
        6   B B B B B B B B B
        
        On numérote les cases de passage du labyrinthe obtenues pour : i et j impairs
        les B correspondentaux murs de bord et de coin
        les - correspondent aux cases de murs horizontaux : codés "H" : i pair, j impair
        les V correspondent aux cases de murs verticaux   : codés "V" : i impair, j pair
        les + correspondent aux cases de murs de coin reliant un mur horizontal et un mur vertical : i pair, j pair
            codés "B" 
            
        Donc après _init_grid_and_walls(), self.grid contient le tableau suivant :
            
          j 0  1  2  3  4  5   6  7   8
        i    
        0   B  B  B  B  B  B   B  B   B
        1   B  0  V  1  V  2   V  3   B
        2   B  H  B  H  B  H   B  H   B
        3   B  4  V  5  V  6   V  7   B
        4   B  H  B  H  B  H   B  H   B
        5   B  8  V  9  V  10  V  11  B
        6   B  B  B  B  B  B   B  B   B        
            
        '''
        self.m, self.n = m, n  #nombre de cases vides avant suppression des murs
        self.l = 2*m + 1   # nombre de lignes
        self.c = 2*n + 1   # nombres de colonnes
        self.grid = [["B"]*(2*n + 1) for i in range(2*m + 1)]  # Tableau (2m+1) x (2n+1)
        self.walls = [] # liste des murs à casser pour créer le labyrinthe donnés comme couples (i, j)
        self.chemins = {} # dictionnaire (identifiant: {cellules d'une même composante connexe})
        self.images_en_construction = []  # images créées à chaque étape du creusement du labyrinthe.
        self.palette_couleurs = []
        self.nb_composantes_connexes = m * n # quand cet attribut vaut 1, on peut arrêter de détruire des murs 
        self.init_palette()
        self._init_grid_and_walls(m, n)   # Pour obtenir le tableau ci-dessus avec les nombres et "B", "V", "H"
        self._init_chemins(m, n)          # à initialiser après les numéros de cellule
        self.genere_laby(mode = 1)        # mode = 1 : on crée les images intermédiaires de construction
    
    def get_dimensions(self):
        return self.l, self.c
    
    def get_grid(self):
        return self.grid
            
    def get_walls(self):
        return self.walls
    
    def init_palette(self):
        '''
        crée une palette de couleurs pour associer à chaque composante connexe une couleur
        donnée de la palette
        '''
        self.palette_couleurs = palette_couleurs(self.m * self.n)        
        
    def _init_grid_and_walls(self, m, n):
        '''
        A partir du tableau (2m+1) x (2n+1), rempli de "B", crée le tableau mentionné 
        dans la fonction __init__ et le stocke dans self.grid
        '''
        self._numerote_cases_de_passage(m, n)
        self._init_murs_verticaux(m, n)
        self._init_murs_horizontaux(m, n)
            
    def _numerote_cases_de_passage(self, m, n):
        '''Numérote les cases de passage de 0 à m*n - 1'''
        #i impair et j impairs: cases de passage numérotées de manière croissante
        cpt = 0
        for i in range (1, 2*m + 1, 2):
            for j in range(1, 2*n, 2):   #i impair, j impair
                self.grid[i][j] = cpt
                cpt += 1
    
    def _init_murs_verticaux(self, m, n):
        for i in range (1, 2*m + 1, 2):
            for j in range(2, 2*n, 2):    #i impair, j pair
                self.walls.append((i, j))
                self.grid[i][j] = "V"
    
    def _init_murs_horizontaux(self, m, n):
        for i in range (2, 2*m , 2):
            for j in range(1, 2*n, 2):    #i pair, j impair
                self.walls.append((i, j))
                self.grid[i][j] = "H"
                
    def _init_chemins(self, m, n):
        for i in range (1, 2*m + 1, 2):
            for j in range(1, 2*n, 2):   #i impair, j impair
                val = self.grid[i][j]
                #au départ les chemins sont constitués d'une seule cellule
                #le numéro attribué à celle-ci est l'identifiant du chemin
                self.chemins[val] = {(i, j)} 
                
    def genere_laby(self, mode = 0):
        '''
        Tant qu'il reste des murs à retirer, on les retire. Quand il n'y en a plus, 
        le labyrinthe est terminé.
        
        après genere_laby(), self.grid ne contient plus que des 0, des B, des V et des H
        Les V et H ont été remplacés par l'étiquette la plus basse des deux chemins 
        fusionnés, s'ils sont transformés en case de passage ou bien 
        sont restés intacts si la suppression du mur avait abouti à créer un cycle.
        
        mode 0 : ne crée pas les images de construction
        mode 1 : crée les images de construction 
            (couteux, et peut faire exploser la mémoire pour m, n ~ 50, ok pour m, n ~ 10->20)
        '''
        murs = self.get_walls()
        self.ajoute_image(mode)
        while  murs != [] and self.nb_composantes_connexes > 1:
            mur = random.choice(murs)
            self.ajoute_image_clignotante(mur, mode)  #pas propre. A améliorer avec une classe Cellule
            self.walls.remove(mur)  #qu'on le casse ou non, ce mur n'est plus a examiner
            if self.mur_cassable(mur):                
                self.retire_mur(mur)
                self.ajoute_image(mode)
                self.nb_composantes_connexes -= 1  
    
    def mur_cassable(self, mur):
        '''
        renvoie True si mur est cassable, c'est-à-dire si les voisins séparés par
        le mur ne sont pas sur le même chemin. S'ils le sont, alors casser le mur crée un cycle,
        ce qu'on veut éviter dans cette version du programme.
        '''
        i, j = mur
        laby = self.get_grid()
        etat_mur = laby[i][j]
        cassable = (etat_mur == 'V' and laby[i][j-1] != laby[i][j+1]) or \
                   (etat_mur == 'H' and laby[i-1][j] != laby[i+1][j])
        return cassable
    
    def retire_mur(self, mur_a_casser):
        '''
        retire un mur dans un labyrinthe étant donnée son étiquette "V" ou "H"
        et la valeurs des étiquettes des cases de passage que ce mur sépare.
        Si les cases appartiennent déjà à la même composante connexe (même étiquette),
        retirer le mur aboutirait à créer un cycle, donc on ne le détruit pas.
        Si les cases n'appartiennent pas à la même composante connexe (étiquette différente)
        on détruit le mur en lui attribuant comme étiquette de case de passage l'étiquette la plus basse
        et on fusionne les deux chemins
        '''        
        laby = self.get_grid()
        i, j = mur_a_casser[0], mur_a_casser[1]
        etat_mur = laby[i][j]
        if etat_mur == 'V':
            # on crée un chemin en détruisant le mur et en propageant l'indice le plus petit des deux cases
            # que le mur séparait
            minimum = min(laby[i][j-1], laby[i][j+1])
            maximum = max(laby[i][j-1], laby[i][j+1])
            self.fusionne_chemin(i, j, minimum, maximum)                                   
        elif etat_mur == 'H': #idem pour un mur horizontal
            minimum = min(laby[i-1][j], laby[i+1][j])
            maximum = max(laby[i-1][j], laby[i+1][j])
            self.fusionne_chemin(i, j, minimum, maximum)
        
                            
    def fusionne_chemin(self, i, j, minimum, maximum):
        '''
        i, j repèrent un mur qu'on a fait sauter, séparant deux chemins dont les cases contiennent
        pour l'un l'étiquette minimum et pour l'autre l'etiquette maximum
        La fusion des deux chemins consiste à affecter l'etiquette minimum au chemin qui contient les
        case d'étiquette maximum. Pour ceci, on parcourt toutes les cases du chemin maximum, et on modifie
        leur étiquette en : mininum.
        '''
        laby = self.get_grid()       
        laby[i][j] = minimum
        self.chemins[minimum].add((i, j))
        for cell in self.chemins[maximum]:
            self.chemins[minimum].add(cell)
            laby[cell[0]][cell[1]] = minimum
        del self.chemins[maximum]
  
    def affiche(self):
        '''affichage en mode texte de la grille du labyrinthe
        '''
        for ligne in self.grid:
            print(ligne)      
        
    def cree_image(self):
        '''
        renvoie une image de (2m+1)x(2n+1) pixels pour un labyrinthe (m, n)
        qu'on agrandit x 10 sans antialiasing pour obtenir une image assez grande.
        La solution reliant l'entrée à la sortie est affichée en vert'.
        '''
        laby = self.get_grid()
        lignes, colonnes = self.get_dimensions()
        result = Image.new('RGB', (colonnes, lignes))
        for i in range(lignes):
            for j in range(colonnes):
                if str(laby[i][j]) in 'BVH':
                    result.putpixel((j, i), (0, 0, 0))
                elif laby[i][j] == 'P':  #pour afficher le chemin solution
                    result.putpixel((j, i), (0, 255, 0))                
                else:
                    result.putpixel((j, i), (255, 255, 255))
        result = result.resize((colonnes*15, lignes*15), 0)
        return result
    
    def voisins(self, i, j):
        '''Renvoie la liste des voisins accessibles de la case de passage (i, j)
        Pour ce faire, on teste les cases adjacentes (murs ou case devenue case de passage : porte).
        Si c'est une porte (contient 0), alors la case de passage adjacente à la porte dans la même direction est 
        un voisin accessible depuis (i, j)
        '''
        liste_voisins = []
        # (i+1, j), (i-1, j), (i, j+1), (i, j-1) sont des emplacements de portes (0) ou de murs ("B", "H", ou "V")
        if self.est_une_porte(i+1, j):
            liste_voisins.append((i+2, j))
        if self.est_une_porte(i-1, j):
            liste_voisins.append((i-2, j))
        if self.est_une_porte(i, j+1):
            liste_voisins.append((i, j+2))
        if self.est_une_porte(i, j-1):
            liste_voisins.append((i, j-2))  
        return liste_voisins
    
    def est_une_porte(self, i, j):
        return self.grid[i][j] == 0
    
    def parcours_largeur(self, source:tuple) -> dict:
        '''Effectue un parcours en largeur du labyrinthe à partir du sommet source
        et renvoie un dictionnaire {sommets:distance à la source}. Un sommet est ici
        une case de passage du labyrinthe repérée par ses coordonnées (i, j).    
        '''
        dist = {source: 0}
        courant = {source}
        suivant = set()
        while len(courant) > 0:
            i, j = courant.pop()
            for v in self.voisins(i, j):
                if v not in dist:
                    suivant.add(v)
                    dist[v] = dist[(i, j)] + 1
            if len(courant) == 0:
                courant, suivant = suivant, set()
        return dist
    
    # Inutile pour le moment.
    # def distance(self, s1:tuple, s2:tuple) -> int:
    #     '''renvoie la distance entre les cases s1 et s2 du labyrinthe '''        
    #     dist = self.parcours_largeur(s1)
    #     if s2 in dist:
    #         return dist[s2]
    
    def chemin(self, s1, s2):
        '''
        Au moyen d'un parcours en largeur à partir de s1, détermine le plus court chemin
        entre s1 et s2. C'est aussi ici le seul chemin qui ne repasse pas deux fois par la
        même case (qui rebrousse pas chemin), puisqu'il n'y a pas de cycles. 
        On construit le chemin de s1 à s2 en partant de la fin :
        on part du sommet s2 et considérant sa distance à s1, on cherche parmi ses voisins
        celui à qui a une distance à s1 inférieure de 1. On le concatène au chemin en construction, 
        jusqu'à aboutir à s1.
        '''
        dist = self.parcours_largeur(s1)
        sommet = s2
        path = [s2]  #plus court chemin en construction (par la fin)
        while sommet != s1:
            i, j = sommet
            lst_voisins = self.voisins(i, j)
            for v in lst_voisins:
                if dist[v] == dist[sommet] - 1:
                    sommet = v
                    path = [v] + path  #pour avoir le chemin dans le bon ordre de s1 vers s2
                    break
        return path
    
    def affiche_chemin(self, chemin):
        '''
        chemin est une liste de couples (i, j)
        Si n est la longueur du chemin, cette fonction crée un gif de n images, dont 
        ajoutant à la précédente une case de plus du chemin coloriée en vert.
        '''
        laby = self.get_grid().copy()
        images = [self.cree_image()]
        i, j = chemin[0]
        laby[i][j] = 'P'
        images.append(self.cree_image())
        for k in range(1, len(chemin)):
            i, j = chemin[k]
            laby[i][j] = 'P'
            i = (chemin[k][0] + chemin[k-1][0]) // 2  #pour les cases de portes dont les index sont la moyenne des cases de passage
            j = (chemin[k][1] + chemin[k-1][1]) // 2
            laby[i][j] = 'P'
            images.append(self.cree_image())
        images[0].save('laby.gif', save_all = True, append_images = images, duration = 100, optimize = True, loop = 0)
    
    def ajoute_image(self, mode = 1):
        '''
        ajoute l'image du labyrinthe dans son état actuel en construction à l'attribut images_en_construction
        '''
        if mode == 0:
            return
        else :
            image = self.cree_image_en_construction()
            self.images_en_construction.append(image)
        
    def ajoute_image_clignotante(self, mur, mode = 1):  #fonction pas très propre (liée à notre mode de fabrication des images)
        '''
        Signale le mur en examen lors du processus de destruction de mur
        au moyen du symbole "S", qui servira à colorer le mur en examen en blanc
        '''
        if mode == 0:
            return
        else:
            i, j = mur[0], mur[1]
            tmp = self.grid[i][j]
            self.grid[i][j] = "S"
            self.ajoute_image()
            self.grid[i][j] = tmp
        
    def cree_image_en_construction(self):
        '''
        Crée une image du labyrinthe dans l'état de génération où il se trouve.
        
        - Les cellules de murs "B", "V", "H" (bords, murs verticaux et horizontaux)
        sont représentées par un pixel noir.
        
        - Une cellule de mur "S" est le mur en cours d'examen dans la fonction 
        ajoute_image_clignotante, liée à la fonction cree_image (pas très propre)
        on lui associe la couleur blanc.
        
        - Les cellules contenant une étiquette numérique sont représentées par 
        un pixel de couleur correspondant à leur étiquette, prise dans la palette 
        de couleurs générée aléatoirement lors de la création du labyrinthe.
        '''        
        laby = self.get_grid()
        lignes, colonnes = self.get_dimensions()
        result = Image.new('RGB', (colonnes, lignes))
        for i in range(lignes):
            for j in range(colonnes):
                if str(laby[i][j]) in 'BVH':
                    result.putpixel((j, i), (0, 0, 0))
                elif laby[i][j] == 'S':
                    result.putpixel((j, i), (255, 255, 255))
                else:
                    index_couleur = laby[i][j]
                    color = self.palette_couleurs[index_couleur]
                    result.putpixel((j, i), color)
        result = result.resize((colonnes*15, lignes*15), 0)
        return result
    
    def cree_gif_generation(self):
        '''
        Crée un gif de la génération pas à pas du labyrinthe
        '''
        if len(self.images_en_construction) != 0:
            images = self.images_en_construction
            images[0].save('laby_construction.gif', save_all = True, append_images = images, duration = 500, optimize = True, loop = 0)
            
        
def palette_couleurs(n):
    '''
    Renvoie une liste de n couleurs aléatoires sous forme de triplet (r, g, b) 
    '''
    couleurs = [(random.randint(0, 255), random.randint(0, 255), random.randint(0,255)) for k in range(n)]
    couleurs_choisies = []
    for _ in range(n):
        c = random.choice(couleurs)
        couleurs.remove(c)
        couleurs_choisies.append(c)
    return couleurs_choisies

    

#Programme principal
i = 10
j = 15
laby = Labyrinthe(i, j)
solution = laby.chemin((1, 1), (2*i - 1, 2*j - 1))
laby.affiche_chemin(solution)
laby.cree_gif_generation()
										
					

Réalisé avec Jason, le labyrinthe est modélisé par une grille où initialement chaque case est entourée de murs sur ses quatre bords. Pour générer le labyrinthe, on casse des murs au hasard en s'assurant qu'on ne crée pas de cycle. Voir cette page pour l'algorithme utilisé.

On calcule ensuite au moyen d'un algorithme de parcours en largeur le plus court chemin entre l'entrée et la sortie du labyrinthe (les coins opposés), cela évite d'avoir des chemins qui rebroussent (puisque sans cycle, il n'y a en réalité qu'un seul chemin qui relie sans repasser deux fois par la même case deux points quelconque du labyrinthe).

Le programme produit deux gifs animés illustrant ces algorithmes.

les murs du labyrinthe se cassent progressivement
génération du labyrinthe
le plus court chemin apparait progressivement
Plus court chemin

On peut produire de très grands labyrinthes (à un seul chemin), mais la génération d'images intermédiaires fait exploser la mémoire pour l'instant.

labyrinthe 50x50
Labyrinthe 50*50