Une nuit étoilée

Projets

Pour notre premier projet de la spé NSI dans le lycée Louis Pasteur, on nous à demandé de réaliser une image déssinée en Python. Nous avions libre choix de réaliser le dessin que l’on desirait, on à donc décidé de réaliser un sujet qui nous passione – le Pixel Art. Seulement, déssiner un pixel art en python demande beaucoup de travail et de réflexion.

Pour valoriser encore plus notre travail, nous avons décidé de rendre cette image animée.

C’est pour cela que le prof nous à suggeré d’utiliser les fonctions, et dans la suite de cet article, nous vous présenterons les techniques qui nous ont permi d’optimiser et de réaliser notre animation représentant un Pixel Art d’une nuie étoilée.

Schéma Original

Tout d’abord, Léandre à commencé par schématiser notre idée sur un logiciel de déssin pixelart – Aseprite.

Nous avons décidé de transcrire seulement les rideaux et la fenêtre en python, sinon le travail était trop manuel, long, et ne demandait pas tant de réfléxion.

Reconstitution des rideaux en Python

Commençons par le commencement :

Nous avons défini les constantes suivantes, qui vont nous aider plus tard.

SCREEN_WIDTH = 1280 + 10
SCREEN_HEIGHT = 720 + 10

PIXEL_SIZE = 15.25

SNOWFLAKE_COUNT = 50
SNOWFLAKE_SIZE_RATIO = 0.1
SNOWFLAKE_MOVE_DOWN = -10

STAR_COUNT = 100

WAIT_TIME_MS = 100
TOTAL_GENERATIONS = 100

Puis, nous avons transcrit manuellement le schéma en python à l’aide de boucles for, et d’une fonction spéciale, conçue par Clovis, nommée draw_pixel_symmetric(couleur, x, y).

Pourquoi symmetric? Car cela nous permet de réaliser 2 rideaux en même temps, symétrique à l’axe vertical, en 2 fois moins de lignes !

D’ailleurs, fun fact. Nous avions pris plus d’une heure à trouver comment réaliser cette symétrie. Clovis était parti dans l’écriture d’un algorithme de dessin de pixels sous forme de dictionnaires {"y = 0": ["Couleur du pixel en x = 0", "Couleur du pixel en x = 1", "etc.."], "y 1":["..."]} avant de trouver une solution plus simple, efficace qui occupe un peu moins de 10 lignes.

def draw_pixel_symmetric(couleur, x, y):
    # OG PIXEL
    penup()
    goto(x, y)
    pendown()
    color(couleur)
    begin_fill()
    for _ in range(4):
        forward(PIXEL_SIZE)
        left(90)
    end_fill()
    penup()

    # SYMMETRIQUE PIXEL
    goto(-x, y)
    pendown()
    begin_fill()
    for _ in range(4):
        forward(PIXEL_SIZE)
        left(90)
    end_fill()
    penup()

C’est évident – au lieu d’avoir un seul pixel en x, y, nous l’avons dupliqué et placé le doublon en -x, y.

Seulement, si c’était si simple.. Nous avons crée la fonction rideaux() où se focalise la transcription manuelle. Merci à Léandre d’avoir transcrit l’entièreté du rideau (2h de travail : 1400 lignes de code).

def rideaux():
    x = -SCREEN_WIDTH//2
    y = -SCREEN_HEIGHT//2 + PIXEL_SIZE
    
    for _ in range(2):
        draw_pixel_symmetric('#9e1b1b',x,y)
        x = x + PIXEL_SIZE

    x = -SCREEN_WIDTH//2
    y = -SCREEN_HEIGHT//2 + PIXEL_SIZE * 2

    draw_pixel_symmetric('#9e2828',x,y)
    x = x + PIXEL_SIZE

    draw_pixel_symmetric('#ac3232',x,y)
    x = x + PIXEL_SIZE
    
    # ... Et la suite dont on vous fait part d'abstraction.
    

rideaux() sans système de symétrie

rideaux() avec draw_pixel_symmetric(). Et voici donc le résultat final. Pas mal non ? Ce n’est seulement que la première étape.

Système de gradient

Le système de gradient, développé par Clovis, prend 5 arguments :

  • colors (liste ["couleur 1", "couleur 2"])
  • x_start
  • y_start
  • x_end
  • y_end

Sans oublier une fonction qui converti l’héxadécimal en décimal, pour pouvoir manipuler des couleurs RGB, et assurer une transition des couleurs de manière fluide.

def hex_to_rgb(hex_color):
    hex_color = hex_color.lstrip('#')
    return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))

def draw_gradient(colors, x_start, y_start, x_end, y_end):
    penup()
    setheading(0) # tortue regarde à l'est
    current_y = y_start
    total_steps = y_end - y_start

    start_rgb = hex_to_rgb(colors[0])
    end_rgb = hex_to_rgb(colors[1])

    while current_y <= y_end:
        proportion = (current_y - y_start) / total_steps
        interpolated_rgb = tuple(
            int(start + (end - start) * proportion) 
            for start, end in zip(start_rgb, end_rgb)
        )
        pencolor(interpolated_rgb)
        penup()
        goto(x_start, current_y)
        pendown()
        forward(x_end - x_start)
        current_y += 1

Expliquons la fonction draw_gradient()plus en détail :

Celle-ci effectue une boucle while qui s’arrête lorsque current_y dépasse ou est égal aux coordonnées y de destination (y_end).

Pour calculer la valeur RGB correspondante à la position de current_y, nous effectuons une boucle for qui consiste à construire un tuple contenant les valeurs rouge, vert, et bleu en fonction de start_rgb, end_rgb et la valeur qui change selon current_y : proportion.

Résultat avec les valeurs hexadécimales ["#4a4c9b", "#050758"].

Appel de la fonction complète : draw_gradient(["#4a4c9b", "#050758"], -SCREEN_WIDTH//2, -SCREEN_HEIGHT//2, SCREEN_WIDTH//2, SCREEN_HEIGHT//2)

Les étoiles

Pour pimenter notre gradient, nous avons placé des étoiles à des emplacements aléatoires.

Voici l’algorithme que nous avions développé.

def initialize_positions(count, screen_width, screen_height):
    # liste avec des tuples dedans PRCQ ON ADORE LES TUPLES !!!!! TOUCHE PAS A MON TUPLE !!!
    positions = []
    for _ in range(count):
        positions.append((randint(-screen_width // 2, screen_width // 2), randint(-screen_height // 2, screen_height // 2)))
    
    return positions

def draw_stars(stars):
    for (x, y) in stars:
        draw_pixel(2, x, y, '#ffffff')

stars = initialize_positions(STAR_COUNT, SCREEN_WIDTH, SCREEN_HEIGHT)
draw_stars(stars)

Pour générer les positions aléatoires, on à décidé de créer une fonction initialize_positions(count, screen_width, screen_height) qui prend en compte les arguments correspondant au montant désiré, la largeur et la hauteur de l’écran. Cette fonction renvoie une liste [] remplie de tuples (x, y) contenant les positions aléatoires en x et y. Créer cette fonction nous permettra d’optimiser encore plus notre système de flocons dans la prochaine section de cet article.

Puis, une fonction draw_starts(stars) à été réalisée pour looper dans chaques tuples contenus dans la liste stars et placer un pixel de taille 2 à l’emplacement correspondant.

Gradient + Etoiles

Gradient + Etoiles + Rideaux

Les compléments des rideaux

Pour ajouter un peu plus de détails, nous avons décidé de rajouter un mur en bas de l’image et un cadre de fenêtre.

De plus, nous avons détaillé les couleurs en rajoutant un système de randomization pour altérner les couleurs marron (claires & foncées). Pour faire cela, il fallait inventer un système pour que chaques pixels ai une variation de marron propre à celui-ci. (Vu qu’il y a une animation plus tard, faire colour_variations_marron[randint(0, 1)] changerait la couleur du pixel à chaques générations).

colour_variations_marron = []
# initialisation des variations de couleurs une seule fois
colour_variations_marron = tuple(
    tuple("#63352d" if randint(0, 1) == 0 else "#5e3129" for _ in range(SCREEN_HEIGHT))
    for _ in range(SCREEN_WIDTH)
)
def supplement_rideaux():

    x = -SCREEN_WIDTH//2 
    y = SCREEN_HEIGHT//2

    # Barre horizontale

    for _ in range(44):
        draw_pixel_symmetric('#4e2b25',x,PIXEL_SIZE * 2.2)
        x = x + PIXEL_SIZE

    x = 0 - PIXEL_SIZE * 12
    y = SCREEN_HEIGHT//2

    # Double Barre Verticale

    for _ in range(45):
        color = colour_variations_marron[round(x)][round(y)]
        draw_pixel_symmetric(color, x, y)
        draw_pixel_symmetric(color, x - PIXEL_SIZE // 1.5, y)
        y -= PIXEL_SIZE

    x = -SCREEN_WIDTH//2
    y = -SCREEN_HEIGHT//2 + PIXEL_SIZE * 8
    
    # Barre horizontale au dessus du mur

    for _ in range(43):
        draw_pixel_symmetric('#4e2b25',x,y)
        x = x + PIXEL_SIZE
    
    x = -SCREEN_WIDTH//2
    y = -SCREEN_HEIGHT//2

    # Mur en bas

    for _ in range(7):
        for _ in range(22):
            for _ in range(3):
                color = colour_variations_marron[round(x)][round(y)]
                draw_pixel(PIXEL_SIZE,x,y,color)
                x = x + PIXEL_SIZE
            
            draw_pixel(PIXEL_SIZE,x,y,'#8f563b')
            x = x + PIXEL_SIZE
        x = -SCREEN_WIDTH//2
        y += PIXEL_SIZE
        

Et voici le résultat !

Les flocons animés

Cette dernière partie était la plus intéressante à faire. Beaucoup de travail et de recherches mais pour un résultat surprenant.

Voici la fonction draw_snowflake() qui déssine un flocon en x, y. Le choix d’une couleur est possible grâce à l’argument couleur. Et la taille de celui-ci est désirable grâce à l’argument size_ratio. Étonnement, Clovis était celui qui à désinné ce flocon, et à trouvé cela plus compliqué comparé aux autres fonctions développées dans tout le projet (ça à pris du temps et de l’imagination).

def draw_snowflake(size_ratio, og_x, og_y, couleur):
    color(couleur)
    x, y = og_x, og_y
    ps = 7 * size_ratio
    # pixel(ps, og_x, og_y)

    goto(og_x - 8*ps, og_y - 8*ps)
    x, y = pos()
    
    left(90)
    pendown()

    for _ in range(16):
        draw_pixel(ps, x, y)
        x, y = x + ps, y + ps
        goto(x, y)

    goto(og_x - 8*ps, og_y + 7*ps)
    x, y = pos()

    for _ in range(16):
        draw_pixel(ps, x, y)
        x, y = x + ps, y - ps
        goto(x, y)

    goto(og_x - ps, og_y + ps * 8)
    x, y = pos()
    og_x2, og_y2 = x, y

    for p in range(18):
        goto(og_x2, og_y2 - (p * ps))
        x, y = pos()
        for i in range(2):
            draw_pixel(ps, x + (i * ps), y)
            x, y = pos()

    goto(og_x - 8 * ps, og_y- ps)
    x, y = pos()
    og_x2, og_y2 = x, y

    for p in range(16):
        goto(og_x2 +  (p * ps), og_y2)
        x, y = pos()
        for i in range(2):
            draw_pixel(ps, x , y+ (i * ps))
            x, y = pos()
    penup()

Quel banger – Léandre quand Clovis à montré son flocon pour la premère fois

Ensuite, passons au système de mouvement des flocons.

def update_snowflake_positions(current_positions):
    updated_positions = []
    for (x, y) in current_positions:
        new_y = y + SNOWFLAKE_MOVE_DOWN
        # snowflake pos reset if goes too down
        if new_y < -SCREEN_HEIGHT // 2:
            new_y = SCREEN_HEIGHT // 2
            new_x = randint(-SCREEN_WIDTH // 2, SCREEN_WIDTH // 2)
            updated_positions.append((new_x, new_y))
        else:
            updated_positions.append((x, new_y))
    return updated_positions

snowflakes = initialize_positions(SNOWFLAKE_COUNT, SCREEN_WIDTH, SCREEN_HEIGHT)

Pour commencer, la fonction update_snowflake_positions(current_positions) prend en compte la liste [(x, y), (x, y)] contenant les tuples de positions x, y de chaques flocons. (réf. initialize_positions(count, screen_width, screen_height)).

Puis initialise une nouvelle liste updated_positions qui contient une nouvelle version de chaques tuples (x, y), cette fois-ci avec une valeur y différée selon la constante SNOWFLAKE_MOVE_DOWN (new_y = y + SNOWFLAKE_MOVE_DOWN).

En plus, nous avons ajouté un système qui replace le flocon en haut de l’écran si les coordonnées y de celui-ci dépasse le point le plus bas de l’écran.

  • Point le plus haut de l’écran : SCREEN_HEIGHT // 2
  • Point le plus bas de l’écran : -SCREEN_HEIGHT // 2

La valeur SCREEN_HEIGHT est divisée par 2, car le module turtle fait en sorte que la position (0, 0) correspond au centre absolu de l’écran.

Enfin, voici la boucle finale permettant une animation.

for generation in range(1, TOTAL_GENERATIONS + 1): # main loop
    clear()

    snowflakes = update_snowflake_positions(snowflakes)
    draw_snowflakes(snowflakes)

    update()

    print(f"Generation n'{generation} dessinée :D")

    time.sleep(WAIT_TIME_MS / 1000)  # Converti millisecondes en seconds

Explication des termes :

  • clear() permet d’effacer complètement tous les éléments de la fenêtre turtle.
  • update() permet de mettre à jour la fenêtre, utile pour réaliser des animations.
  • time.sleep(WAIT_TIME_MS / 1000) permet un temps d’attente entre chaques image exposée. Ici la constante WAIT_TIME_MS est de 100, et la fonction time.sleep() ne prend seulement comme argument des secondes. Il faut donc convertir les millisecondes en secondes, signifiant une division par 1000.

Si TOTAL_GENERATIONS = 1, alors turtle nous renverra une seule image, et non un animé.

Autrement, si TOTAL_GENERATIONS est au dessus de 1, on obtient une animation. Voici un exemple avec 100 :

Pas mal non ?

Sources

Pour réaliser toutes ces fonctions, nous avons utilisé nos propres connaissances, logique, et w3schools qui nous à beaucoup servi pour comprendre certaines opérations et fonctions integrées dans python (comme la fonction zip).

Image et animation finale

Télécharger le projet

Remarque des enseignants : les élèves n’ont pas respecté le nombre maximal de lignes imposé (404) ni la charte de nommage des variables. Cela pose de nombreux problèmes. L’article est néanmoins publié.