Ontem, sábado, fiquei com vontade de criar um efeito de flamas (fogo) em Python. Este efeito era bem popular no início dos anos 90. Eu lembrava que o algoritmo era bem simples, mas tinha uns truques a fazer com a paleta de cores.

Achei este artigo com a implementação em C: https://lodev.org/cgtutor/fire.html

Do mesmo artigo, podemos ter uma ideia de como fica o efeito:

Efeito de flamas

Depois de ler o artigo e ver uns vídeos no Youtube, vi-me com dois problemas:

  1. Precisava de uma aplicação gráfica capaz de mostrar imagens, como uma animação, uma imagem após a outra, o mais rápido possível (no mínimo uns 15 frames por segundo, idealmente acima de 30).

  2. Suspeitei que teria problemas de velocidade para gerar as imagens, uma vez que uma mera imagem de 1024 x 1024 tem muitos pontos e usa uns 3 bytes por ponto. Imaginando uma matriz deste tamanho para trabalhar em Python, vi que não seria tão fácil escrever esta parte apenas em Python. Instalei o numpy para garantir.

Eu esperava que o problema um seria relativamente simples, mas já explico o que complicou um pouco. Como eu quero apenas mostrar uma imagem, o tkinter do Python já seria suficiente. Comecei por criar uma aplicação simples, mostrando um Canvas e adicionando uma imagem. Porém, devido ao problema dois, durante o tempo para gerar a imagem, a tela fica completamente bloqueada, você não consegue mover ou fechar a janela.

O código está em português e em inglês, mas basicamente é uma aplicação tkinter, onde a janela principal tem um Label para apresentar uma mensagem, no caso, o número do frame corrente; e uma imagem.

class App(tk.Tk):
    def __init__(self, desenhador, func, preFunc, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setup_windows()
        self.queue = Queue()
        self.queueStop = Queue()
        self.setup_thread(desenhador, func, preFunc)
        self.buffer = None
        self.running = True
        self.dead = False
        self.after(1, self.check_queue)

    def setup_windows(self):
        self.title('Gerador de Imagens')
        self.status = tk.StringVar(self, value='Aguardando')
        tk.Label(self, textvariable=self.status).pack()
        self.canvas = tk.Canvas(self, width=LARGURA, height=ALTURA)
        self.image = self.canvas.create_image(0, 0, anchor=tk.NW)
        self.canvas.pack()
        self.protocol("WM_DELETE_WINDOW", self.terminate)

O método setup_windows configura a janela, adicionando o Label, criando o Canvas e a imagem. Como vamos trocar a imagem frequentemente, ela também guarda uma referência a imagem no canvas em self.image. Este método também configura a janela para chamar self.terminate caso o usuário a feche.

setup_thread faz a inicialização do thread, a classe que gerencia o thread é passada como desenhador ao __init__. Para facilitar a comunicação com o thread, duas filas foram criadas, uma para receber as mensagens vindas do thread (self.queue) e outra para esperar a finalização do thread self.queueStop (mais detalhes depois). func e preFunc são duas funções usadas para facilitar os testes, onde as funções que realizam o desenho da imagem podem ser passadas como parâmetro. preFunc gera a primeira imagem e func é chamada dentro de um loop para gerar as imagens (frames seguintes). O thread desenhador é iniciado imediatamente após sua criação.

    def setup_thread(self, desenhador, func, preFunc):
        self.desenhador = desenhador(self.queue, self.queueStop, func, preFunc)
        self.desenhador.start()

Uma vez que a janela e o thread que atualiza as imagens já foram criados, precisamos de um método que fique periodicamente verificando se há novas imagens na fila. Este método é o check_queue, chamado no __init__ com self.after(1, self.check_queue). O uso de self.after é capital, pois começa a executar check_queue fora do __init__, depois da criação da janela e do loop de eventos.

check_queue verifica se a fila com as imagens geradas pelo thread está vazia. Caso esteja, não faz nada, mas caso contrário, pega a nova imagem e troca a imagem do Canvas. No final, se agenda para rodar de novo 10 ms depois e repete este processo para trocar as imagens o quanto antes.

    def check_queue(self):
        if not self.queue.empty():
            contador, self.buffer = self.queue.get()
            self.status.set(f"Frame: {contador}")
            self.canvas.itemconfig(self.image, image=self.buffer)
            self.queue.task_done()
        if self.running:
            self.after(10, self.check_queue)

Quando trabalhamos com múltiplos threads no tkinter e na maioria dos frameworks GUI, normalmente, só podemos alterar os objetos geridos pelo framework no mesmo thread que roda o mainloop. É por isso que a imagem é trocada em check_queue. Isto também leva a outros problemas a gerir entre os threads e a GUI. Por exemplo, a conversão de uma imagem, realizada no thread que desenha (detalhes depois), precisa que o tkinter esteja rodando e processando eventos, mesmo sendo um objeto fora da tela e não associado a nenhum controle. Esta é uma característica do tkinter. E por isto, terminate chama check_thread_dead para matar o thread, mas esperando como o loop principal da tkinter rodando. Veja que o desenhador é parado com self.desenhador.stop(). Depois, check_thread_dead é chamada para verificar se o desenhador realmente parou, é neste momento que usamos a outra fila, queueStop. Esta fila fica vazia durante a execução do programa e só recebe algo quando o loop do desenhador termina seu trabalho. So então que o loop da tkinter é destruído com a chamada a self.destroy().

    def check_thread_dead(self):
        if self.queueStop.empty() and not self.dead:
            self.after(1, self.check_thread_dead)
            return
        self.queueStop.get()
        self.dead = True
        self.desenhador.join()
        self.destroy()

    def terminate(self, e=None):
        self.running = False
        if not self.dead:
            self.desenhador.stop()
            self.check_thread_dead()

Isso tudo apenas para ter a janela sendo atualizada por outro thread. Ainda não desenhamos nada. Vejamos uma implementação de desenhador:

class Desenha(Thread):
    def __init__(self, queue, queueStop, func, preFunc, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.queue = queue
        self.queueStop = queueStop
        self.running = True
        self.func = func
        self.preFunc = preFunc or func

    def run(self):
        try:
            data = numpy.zeros((ALTURA, LARGURA, 3), dtype=numpy.uint8)
            c = 0
            self.preFunc(data, c, LARGURA, ALTURA)
            while self.running:
                with TimeIt("Loop") as t:
                    # with TimeIt("ForLoop") as t:
                    self.func(data, c, LARGURA, ALTURA)
                    # with TimeIt("FROM ARRAY") as t1:
                    image = Image.fromarray(data)
                    # with TimeIt("Convert") as t2:
                    converted_image = ImageTk.PhotoImage(image)
                    # with TimeIt("Queue") as t3:
                    self.queue.put((c, converted_image))
                    c += 1
        finally:
            self.running = False
            self.queueStop.put((0, 'FEITO'))

    def stop(self):
        self.running = False

A classe Desenha recebe as filas para onde vai enviar as imagens que serão criadas dentro do run e também a mensagem que indica ter terminado. O trabalho em si é realizado dentro de run que é executado quando o thread é iniciado.

Como as matrizes são grandes, com facilmente mais de 1 milhão de elementos para imagens 1024 x 1024 pontos, Desenha utiliza arrays otimizados da numpy. Seria simplesmente muito mais lento trabalhar com listas do python para realizar estas operações, pois temos que preencher todos os pontos a cada imagem.

Se você não conhece a NumPy, ela é uma biblioteca muito utilizada em ciência de dados e várias outras áreas que precisam realizar operações com matrizes e efetuar cálculos matemáticos em geral em Python. Voce pode ler a documentação aqui NumPy. A grande vantagem da NumPy é ser otimizada em C, além de fazer parte do Scipy.org.

Voltando ao run, ele basicamente cria uma matriz com tamanho suficiente para representar os pontos da nova imagem que vamos criar. Estas dimensões são ALTURA e LARGURA em 3 dimensões, uma para cada componente de cor RGB (Vermelho, Verde e Azul; um byte para cada). Assim, com uma imagem de 1024 x 1024 pontos, temos 1024 x 1024 x 3 = 3.145.728 bytes só para armazenar a matriz de pontos.

Uma vez que a matriz é criada, run chama a função de desenho self.preFunc, que realiza o desenho da primeira imagem, passando a matriz, um contador de frames, assim como as dimensões da imagem. Esta assinatura foi se desenvolvmendo conforme eu precisei fazer testes. Depois, chama dentro do loop principal self.func, com os mesmos parâmetros, mas para criar as imagens seguintes. Esta organização com preFunc e func foi nécessária para melhor visualizar os dados, uma vez que o algoritmo de desenho que comecei a usar para testes não dava uma resposta visual rápida. Logo, usei preFunc para desenhar uma imagem e func para modificá-la, como por exemplo, movendo suas linhas para cima.

Como precisamos trocar as imagens o mais rápido possível (30 frames por segundo = ~33 ms entre cada imagem), run deve executar seu loop o mais rápido possível.

O passo seguinte, que independe de como a imagem foi criada, é transformar a matriz de pontos numa imagem. Esta transformação é realizada por image = Image.fromarray(data). A partir deste ponto, temos uma imagem, porém esta está no formato da PILLOW (PIL), biblioteca de imagens que usamos para fazer esta gestão. Para converter nossa imagem para o tkinter, também usando a PILLOW, chamamos: converted_image = ImageTk.PhotoImage(image). converted_image está pronta para ir para a fila e ser desenhada na tela. Já podemos passar para o desenho da imagem seguinte.

Uma curiosidade é que foi justamente a ImageTk.PhotoImage que complicou o processo de finalização dos threads, é esta classe que precisa do loop de eventos do tkinter rodando para funcionar e fez com que uma cordenação de finalização fosse elabadora com o queueStop.

O loop de run fica rodando até self.running ser False. E é exatamente isto que o método stop faz.

Como o thread de desenho é independente do thread principal do programa, onde roda a tkinter, o stop pode ocorrer em momentos diferentes do loop. É por isso que não podemos desativar a tkinter até que o loop seja finalizado e chegue novamente no while que verifica self.running.

Ao sair do loop, uma mensagem a queueStop é postada. Esta mensagem serve de sinal para que o loop principal continue sua finalização e posteriormente feche a janela.

Você deve ter reparado várias chamadas comentadas a TimeIt. Esta classe foi criada apenas para medir o tempo de execução de algumas funções, pois percebi que estava muito lento.

class TimeIt:
    """Classe para medir o tempo de execução de alguns blocos.
       Deve ser usada como gerenciados de contexto, com blocks with"""
    def __init__(self, name, silent=False):
        self.name = name
        self.start = 0
        self.end = 0
        self.silent = silent

    def __enter__(self):
        self.start = datetime.now()

    def __exit__(self, *args, **kwargs):
        self.end = datetime.now()
        if not self.silent:
            segundos = self.elapsed().total_seconds()
            if segundos == 0:
                return
            fps = 1.0 / segundos
            print(f"Elapsed {self.name}: {self.elapsed()} Frames: {fps}")

    def elapsed(self):
        return self.end - self.start

Antes de tudo otimizar era preciso descobrir a origem da lentidão. No caso do loop, era sempre a chamada a self.func que dominava o tempo de execução. Você pode remover os comentários e identar a linha seguinte para ter os resultados na tela. A operação de Image.fromarray e ImageTk.PhotoImage executam muito rápido, na casa de 1 ms em meu computador. Já a função de desenho estava demorando até 3s ou 3000 ms no início. Lembrando que precisamos desenhar em no máximo 33 ms para termos 30 frames por segundo.

Vejamos uma função simples de desenho:

def draw(data, c, largura, altura):
    for y in numpy.arange(0, altura):
        for x in numpy.arange(0, largura):
            data[y, x] = [0, 0, y // (c + 1)]

Esta função simplesmente desenha uma série de listras na tela, mudando a componente azul de cada ponto com a divisão da linha corrente pelo contador de frames (c). A ideia era apenas de percorrer os pontos da imagem e poder visualizar na tela o mais rápido possível.

Esta função tem uma performance horrível:

Elapsed Loop: 0:00:01.427400 Frames: 0.7005744710662744
Elapsed Loop: 0:00:01.316119 Frames: 0.7598097132554122
Elapsed Loop: 0:00:01.308270 Frames: 0.764368211454822
Elapsed Loop: 0:00:01.341486 Frames: 0.7454419949220491
Elapsed Loop: 0:00:01.359058 Frames: 0.7358037699641957

Menos de 1 frame por segundo, já que estamos passando mais de 1s para gerar uma única imagem.

Tela Azul

Como tempo, a imagem fica cada vez mais escura, em função dos valores de c que aumentam a cada frame. Mas com esta velocidade, ficou muito lento e você mal percebe que há alguma mudança na tela em si.

Mesmo usando NumPy, o tempo de execução do loop de desenho era muito alto. Decidi então usar outra biblioteca, chamada Numba. Numba é um JIT (Just in Time compiler) para Python. Com ela, você pode anotar suas funções e elas são compiladas logo na primeira chamada. Ao chamar outra vez, a função original é substituida pela compilada, otimizada e rodando com performances de linguagens nativas (desde que a interação com o interpretador seja limitada). Vejamos o que precimos mudar para usar Numba:

@jit(nopython=True, parallel=True, fastmath=True, nogil=True)
def drawNumba(data, c, largura, altura):
    for y in numpy.arange(0, altura):
        for x in numpy.arange(0, largura):
            data[y, x] = [0, 0, y // (c + 1)]

O código é o mesmo, simplesmente adicionamos o decorador @jit da Numba para marcar que queremos que esta função seja otimizada. Nada mais no código foi mudado, salvo o import da Numba em si. Para o resto do programa, a função se comporta da mesma forma que antes.

Vejamos o resultado com Numba:

Elapsed PreLoop: 0:00:01.276058 Frames: 0.7836634384957424
Elapsed Loop: 0:00:00.210905 Frames: 4.74147127853773
Elapsed Loop: 0:00:00.205027 Frames: 4.877406390377853
Elapsed Loop: 0:00:00.219663 Frames: 4.552428037493797
Elapsed Loop: 0:00:00.220664 Frames: 4.5317768190552155
Elapsed Loop: 0:00:00.209917 Frames: 4.763787592238837
Elapsed Loop: 0:00:00.222617 Frames: 4.492019926600395

Eu adicionei um contexto TimeIt para medir o tempo de execução da primeira chamada a função, no caso, preFunc. Veja que a função executou praticamente com a mesma lentidão da versão sem aceleração. Porém, observe que a partir da segunda chamada, o tempo de execução foi reduzido de 1.27s para 0.21s, elevando nossos frames por segundo a 4.7 (o número de frames na tela pode ser menor ou um pouco diferente devido a comunicação com a tkinter). A versão acelerada com Numba, roda em apenas 16% do tempo, ou seja, é quase 8 vezes mais rápida. Tudo isso com a instalação via pip e duas linhas no código. Mas 4 frames por segundo ainda é muito lento e longe dos 30 desejados. Lembrando que até agora nem comecei a fazer o efeito de chamas.

Outra alternativa é usar um módulo compilado, criado com Cython. Cython (diferente de CPython), é um compilador que traduz um programa parecido com Python em um módulo C que o Python pode chamar.

Para usar Cython, precisamos fazer algumas mudanças mais importantes. Primeiro instalar o Cython e ter certeza que um compilador C/C++ está instalado na máquina. No Windows com Python 3.8, usei o Visual Studio 2019 sem problemas.

Um programa em Cython é escrito num arquivo com a extensão .pyx. Convertendo a função de desenho para Cython, temos:

import numpy as np
cimport numpy as np
cimport cython
from libc.math cimport abs
from libc.stdlib cimport rand


ctypedef np.uint8_t DTYPE_t
ctypedef np.uint32_t DTYPE32_t


@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
@cython.cdivision(True)
def draw2(np.ndarray[DTYPE_t, ndim=3] data, int c, int max_x, int max_y):
    cdef int x, y
    cdef int ic = c
    cdef np.ndarray[DTYPE_t, ndim=3] h = data
    cdef int cmax_y = max_y, cmax_x = max_x
    for y in range(cmax_y):
        for x in range(cmax_x):
            h[y, x, 0] = 0
            h[y, x, 1] = 0
            h[y, x, 2] = y / (ic + 1)

Muito parecido com Python e com C.

O Cython pede também a configuração de um setup.py para compilar o módulo.

from setuptools import setup
from Cython.Build import cythonize
import numpy

setup(
    name='Gerador de Telas',
    ext_modules=cythonize("compute.pyx", annotate=True, language_level=3),
    include_dirs=[numpy.get_include()],
    zip_safe=False,
)

E precisa ser compilado com:

python setup.py build_ext --inplace

Mas os resultados são muito bons:

Elapsed Loop: 0:00:00.023445 Frames: 42.65301770100235
Elapsed Loop: 0:00:00.022442 Frames: 44.55930843953302
Elapsed Loop: 0:00:00.023414 Frames: 42.70949004868882
Elapsed Loop: 0:00:00.024410 Frames: 40.96681687832855
Elapsed Loop: 0:00:00.023431 Frames: 42.67850283812044
Elapsed Loop: 0:00:00.022455 Frames: 44.533511467379206
Elapsed Loop: 0:00:00.023436 Frames: 42.66939750810719

Agora passamos de 4 para 40 frames por segundo e geramos uma nova imagem em apenas 23 ms!

Na realidade, ficou tão rápido que a imagem fica preta muito rápido. Para faciliar a visualização, uma outra função, chamada drawUp for criada. Para não ficar escurecendo a imagem, resolvi copiar as linhas de forma a rolar as linhas na tela, desta forma, o programa pode rodar por mais tempo, sem que a tela fique negra.

@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
@cython.cdivision(True)
def drawUp(np.ndarray[DTYPE_t, ndim=3] data, int c, int max_x, int max_y):
    cdef int x, y
    cdef int ic = c
    cdef np.ndarray[DTYPE_t, ndim=3] h = data
    cdef int cmax_y = max_y, cmax_x = max_x
    # Copy top to bottom
    for x in range(0, cmax_x):
        h[cmax_y - 2, x, 0] = h[0, x, 0]
        h[cmax_y - 2, x, 1] = h[0, x, 1]
        h[cmax_y - 2, x, 2] = h[0, x, 2]
    for y in range(1, cmax_y - 1):
        for x in range(0, cmax_x):
            h[y - 1, x, 0] = h[y, x, 0]
            h[y - 1, x, 1] = h[y, x, 1]            
            h[y - 1, x, 2] = h[y, x, 2]

Foi está mudançã que levou a separação entre preFunc e func. No preFunc, executada por draw2, uma imagem como a gerada em Python puro é criada. Na função drawUp, ela simplesmente rola as linhas da imagem, copiando a linha do topo para baixo e movendo as outras linhas para cima.

Neste ponto, tanto o problema de performance quanto de finalização da janela foram resolvidos. Falta apenas converter o algoritmo para gerar as flamas.

O primeiro passo é gerar uma paleta de cores compatíveis, uma vez que o algoritmo usa 256 cores para indicar a intensidade do fogo.

Convertendo para Python, temos algo como:

def build_fire_palette():
    palette = numpy.zeros((256, 3), dtype=numpy.uint8)
    for x in range(256):
        h = x // 3
        saturation = 100
        b = min(256, x * 2) / 256.0 * 100.0
        css = f"hsl({h},{saturation}%,{b}%)"
        palette[x] = ImageColor.getrgb(css)
    return palette

A paleta é simplesmente uma tabela de cores que vamos usar para transformar um valor entre 0 e 255 (byte) em uma cor RGB (vermelho, verde e azul com 3 bytes).

Um problema aperece com o desenhador, pois a classe Desenha não suporta paletas de cores. Vamos precisar de outro desenhador:

class DesenhaComPalette(Desenha):
    def run(self):
        try:
            palette = build_fire_palette()
            data = numpy.zeros((ALTURA, LARGURA), dtype=numpy.uint8)
            fogo = numpy.zeros((ALTURA, LARGURA), dtype=numpy.uint32)
            c = 0
            while self.running:
                with TimeIt("Loop") as t:
                    # with TimeIt("ForLoop") as t:
                    self.func(data, c, LARGURA, ALTURA, fogo)
                    # with TimeIt("FROM ARRAY") as t1:
                    image = Image.fromarray(data, mode="P")
                    image.putpalette(palette)
                    # with TimeIt("Convert") as t2:
                    converted_image = ImageTk.PhotoImage(image)
                    # with TimeIt("Queue") as t3:
                    self.queue.put((c, converted_image))
                    c += 1
        finally:
            self.running = False
            self.queueStop.put((0, 'FEITO'))

A diferença é que criamos a imagem de forma diferente, pois temos que passar os pontos (com cores 0 a 255) e a peleta (com a tradução de cada cor). Criamos também o fogo, mas como uma matriz de inteiros e não como uma matriz de bytes. Isto muda o tamanho da matriz em memória, mas é necessária pro algoritmo das flamas que guarda a informação do fogo entre uma tela e outra. Em data, vamos guardar os pontos em 256 cores.

O algoritmo convertido em Python fica assim:

def desenhaPythonFlamas(data, c, largura, altura, fogo):
    for x in range(LARGURA):
        fogo[ALTURA - 1, x] = int(min(random.random() * 2048, 2048))

    for y in range(1, ALTURA - 2):
        for x in range(0, LARGURA):
            v = int((fogo[(y + 1) % ALTURA, x] +
                     fogo[(y + 1) % ALTURA, (x - 1) % LARGURA] +
                     fogo[(y + 1) % ALTURA, (x + 1) % LARGURA] +
                     fogo[(y + 2) % ALTURA, x]) * 32) / 129
            fogo[y, x] = v
    for y in range(altura):
        for x in range(largura):
            data[y, x] = fogo[y, x] % 256

Que fica ultra lento, como esperado:

Elapsed Loop: 0:00:06.345203 Frames: 0.15759937073723254
Elapsed Loop: 0:00:06.327644 Frames: 0.15803670370836284
Elapsed Loop: 0:00:06.362772 Frames: 0.15716420453223848
Elapsed Loop: 0:00:06.387171 Frames: 0.15656383710409505
Elapsed Loop: 0:00:06.590262 Frames: 0.15173903556489862

São enormes 6s para gerar uma só tela com as flamas! Passemos a versão otimizada com Numba, simplesmente adicionado o decorador, como fizemos anteriormente.

Elapsed Loop: 0:00:00.022445 Frames: 44.55335263978615
Elapsed Loop: 0:00:00.024425 Frames: 40.941658137154555
Elapsed Loop: 0:00:00.024421 Frames: 40.94836411285369
Elapsed Loop: 0:00:00.024396 Frames: 40.99032628299721

Muito melhor! Atingimos mais de 30 frames como esperado, voltando a casa dos 23 ms para gerar um frame. Lembrando que todas estas performances são para imagens de 1024 x 1024 pontos. Se você tem um computador mais lento, pode diminuir o tamanho da tela.

E como ficaria em Cython? Pagando para ver:

@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
@cython.cdivision(True)
def desenhaflamas(np.ndarray[DTYPE_t, ndim=2] data, 
                  int c, int max_x, int max_y, 
                  np.ndarray[DTYPE32_t, ndim=2] fogo):
    cdef int x, y
    cdef int ic = c
    cdef np.ndarray[DTYPE_t, ndim=2] d = data
    cdef np.ndarray[DTYPE32_t, ndim=2] f = fogo
    cdef int cmax_y = max_y, cmax_x = max_x

    for x in range(cmax_x):
        f[cmax_y - 1, x] = abs(32768 + rand()) % 2048

    for y in range(1, cmax_y - 2):
        for x in range(0, cmax_x):
            f[y, x] = ((f[(y + 1) % cmax_y, x] +
                        f[(y + 1) % cmax_y, (x - 1) % cmax_x] +
                        f[(y + 1)% cmax_y, (x + 1) % cmax_x] +
                        f[(y + 2)% cmax_y, x]) * 32) / 129
    for y in range(max_y):
        for x in range(max_x):
            d[y, x] = f[y, x] % 256

Que tem como performance:

Elapsed Loop: 0:00:00.026373 Frames: 37.917567208887874
Elapsed Loop: 0:00:00.026379 Frames: 37.90894271958755
Elapsed Loop: 0:00:00.028309 Frames: 35.324455120279765
Elapsed Loop: 0:00:00.030253 Frames: 33.05457310018841
Elapsed Loop: 0:00:00.028315 Frames: 35.316969803990816
Elapsed Loop: 0:00:00.026374 Frames: 37.91612952149844
Elapsed Loop: 0:00:00.026347 Frames: 37.95498538733063
Elapsed Loop: 0:00:00.026374 Frames: 37.91612952149844

Ficou um pouco pior que com Numba. Eu acredito que seja algum detalhe no código Cython. Mas já deu para ver que o efeito está rodando:

Flamas

Como rodar tudo isso em um só programa? Precisamos de uma seção de configuração:

if len(sys.argv) < 5:
    print("Uso: python desenha.py <algoritmo> <acelerador> <largura> <altura>")
    print("Algoritmo: desenho, flamas")
    print("Acelerador: cython, python, numba")

ALGORITMO = sys.argv[1].lower()
ACELERADOR = sys.argv[2].lower()
LARGURA = int(sys.argv[3])
ALTURA = int(sys.argv[4])

print(f"ALGORITMO: {ALGORITMO}")
print(f"ACELERADOR: {ACELERADOR}")
print(f"LARGURA: {LARGURA} ALTURA: {ALTURA}")

CONFIGURACAO = {
    "flamas": {"desenhador": DesenhaComPalette,
               "otimizacao": {"python": (desenhaPythonFlamas.py_func, None),
                              "cython": (desenhaflamas, None),
                              "numba": (desenhaPythonFlamas, None)
                              }},
    "desenho": {"desenhador": Desenha,
                "otimizacao": {"python": (drawNumba.py_func, None),
                               "cython": (drawUp, draw2),
                               "numba": (drawNumba, None)
                               }}
}

if ALGORITMO not in CONFIGURACAO:
    print(f"Algoritmo {ALGORITMO} inválido", file=sys.stderr)
    sys.exit(1)

if ACELERADOR not in CONFIGURACAO[ALGORITMO]["otimizacao"]:
    print(f"Acelerador {ACELERADOR} inválido", file=sys.stderr)
    sys.exit(2)

if ALTURA < MIN_V or LARGURA < MIN_V or ALTURA > MAX_V or LARGURA > MAX_V:
    print(f"Altura e largura devem ser valores entre {MIN_V} e {MAX_V}.")
    sys.exit(3)

desenhador = CONFIGURACAO[ALGORITMO]["desenhador"]
func = CONFIGURACAO[ALGORITMO]["otimizacao"][ACELERADOR][0]
prefunc = CONFIGURACAO[ALGORITMO]["otimizacao"][ACELERADOR][1]

app = App(desenhador=desenhador, func=func, preFunc=prefunc)
app.mainloop()

Ufa, finalmente rodando! Espero que tenha gostado do artigo e que tenha ficado curioso sobre performance em Python, Cython e Numba. O código completo esta publicado no GitHub: https://github.com/lskbr/flamas_em_python

E se usarmos este código para simular o Jogo da Vida? Fica para outro artigo.