¿Alguna vez escribiste una animación en Python para un microcontrolador y viste cómo avanzaba a dos o tres cuadros por segundo? Es el muro clásico de CircuitPython: es cómodo para prototipar, pero mover cientos de miles de píxeles por cuadro con un intérprete es demasiado lento. Este proyecto muestra cómo saltarse ese muro sin abandonar Python: una cinta transportadora de sushi animada en una pantalla TFT alargada, con la parte pesada del dibujo compilada a código nativo gracias a CircuitPython Turbo y los decoradores Viper heredados de MicroPython.
La idea es útil mucho más allá del sushi: cualquier proyecto con gráficos, procesamiento de señales o manejo de buffers grandes en un ESP32-S3 puede aprovecharla. Al final vas a entender qué hace Viper por dentro, qué reglas impone, cómo se compila un módulo .mpy para tu chip y cómo se organiza un programa para que lo lento quede en Python y lo crítico en código de máquina.
El problema: por qué una animación en CircuitPython va lenta
La autora original, Liz Clark de Adafruit, ya había intentado este mismo proyecto cuando salió la placa Qualia ESP32-S3, pensada para pantallas TTL RGB-666 grandes. En CircuitPython puro la cinta avanzaba demasiado lento y terminó haciéndolo en Arduino con PlatformIO. La versión de este tutorial es la revancha: el mismo efecto, pero en CircuitPython.
Para dimensionar el problema: una pantalla de 320 × 820 tiene 262.400 píxeles. En RGB565 cada píxel son 2 bytes, o sea más de medio megabyte por cuadro. Si cada píxel pasa por el intérprete (leer el índice de color, buscarlo en la paleta, decidir si es transparente, escribirlo), son cientos de miles de operaciones de bytecode por cuadro, cada una con su costo de despacho, verificación de tipos y manejo de objetos. Ahí se va el tiempo, no en la pantalla.
El concepto: qué es Viper y qué cambia CircuitPython Turbo
MicroPython tiene desde hace años tres "emisores" de código: el bytecode normal, @micropython.native (que traduce el bytecode a instrucciones de máquina pero mantiene los objetos de Python) y @micropython.viper, que va más lejos: trabaja con enteros de máquina y punteros directos a memoria. Una función Viper que recorre un buffer se parece mucho a un ciclo en C.
CircuitPython Turbo trae ese mecanismo a CircuitPython: escribes un módulo auxiliar en Python con funciones decoradas con Viper, lo compilas con mpy-cross para la arquitectura exacta de tu chip y obtienes un archivo .mpy que copias a la carpeta /lib de la unidad CIRCUITPY. Las funciones Viper quedan como código de máquina nativo. No todo se acelera igual: la ganancia está en los ciclos con mucha aritmética entera, como los gráficos.
Las reglas de Viper, resumidas de la guía PixelDust Digital Sand de Tim C. (citada por la autora), son las que más hacen tropezar:
- Las variables locales y los argumentos tienen que ser
int,uint(solo 32 bits),bool,ptr,ptr8,ptr16,ptr32uobject. No hay flotantes como en Python normal. - Los parámetros se pasan como una lista de valores y se leen por su índice dentro de esa lista.
- No hay protección contra desbordes. Los enteros dan la vuelta en silencio, y un índice fuera de rango puede corromper memoria o colgar la placa en vez de lanzar un
IndexErrorlimpio. - Una variable no puede cambiar de tipo.
- Mezclar valores tipados con valores
objecthace que el código vuelva a llamadas lentas en tiempo de ejecución.
Por eso el programa del proyecto pasa la configuración en arreglos array('i') (un params con un índice por parámetro, constantes como PARAM_SCROLL) en vez de en un diccionario: un ptr32 sobre un arreglo de enteros es exactamente lo que Viper sabe leer rápido.
Una advertencia práctica: el .mpy compilado es específico de la arquitectura. El que viene en el proyecto está hecho para el Xtensa LX7 del ESP32-S3; en un RP2040 o un ESP32-C3 (RISC-V) no carga. Si cambias de placa, tienes que recompilar el fuente con mpy-cross y la opción de arquitectura que corresponda.
El hardware
El montaje original usa piezas muy específicas de Adafruit:
- Placa Adafruit Qualia ESP32-S3 para pantallas TTL RGB-666: un ESP32-S3 con PSRAM y el conector de 40 pines para pantallas de interfaz paralela RGB, más un expansor de E/S por I²C que inicializa el panel.
- Pantalla TFT alargada tipo barra: la de 3,2" y 320 × 820 con táctil capacitivo, o la de 240 × 960 sin táctil.
- Cable USB-A a USB-C de datos, para cargar CircuitPython y alimentar la placa.
- La caja impresa en 3D (opcional).
Lo interesante del Qualia es el tipo de pantalla. Una TFT común de 1,3" a 2,8" (ST7789, ILI9341) se maneja por SPI: el microcontrolador le manda los píxeles y la pantalla los guarda en su propia memoria. Estas barras alargadas son paneles RGB "dot clock": no tienen memoria propia y hay que refrescarlas continuamente con un reloj de píxel y 16 a 18 líneas de datos en paralelo, como un monitor. El ESP32-S3 tiene un periférico LCD que lo hace por DMA desde un framebuffer en PSRAM, y en CircuitPython eso se expone como dotclockframebuffer. Por eso el código escribe directamente en el framebuffer y después llama a framebuffer.refresh().
La caja de sushi impresa en 3D
La cinta puede ir dentro de una caja impresa, un remix del modelo Sushi Go Nigiri Box de mderoxtro. Tiene dos piezas: una tapa con forma de nigiri y una caja con forma de bola de arroz. Los STL se descargan desde la guía original (sushi_box_stl.zip) o desde Printables.
La tapa necesita soportes. El truco de la autora es parar la pieza sobre su lado más largo: así se generan menos soportes y la cara de arriba queda con mejor terminación. Hay dos versiones de tapa, una para la pantalla de 320 × 820 y otra para la de 240 × 960, así que imprime la que calce con tu panel. La bola de arroz tiene espacio para el Qualia y una ranura atrás para el cable USB que lo alimenta.
El software paso a paso
Instalar CircuitPython en el Qualia S3
Este proyecto necesita CircuitPython 10.4.0-alpha.2 o más nuevo, porque esa es la versión que agregó soporte Viper al port de Espressif. Al 24-sep-2026 la versión de desarrollo más reciente era la 11.0.0-alpha.1.
- Descarga desde circuitpython.org el archivo UF2 más reciente para el Qualia S3.
- Conecta la placa al computador con un cable USB que sepas que transmite datos. Muchos cables solo cargan, y es una de las fuentes de frustración más comunes.
- Haz doble clic en el botón de reset. Aparece una unidad llamada TFT_S3BOOT. Si no aparece a la primera, insiste: a veces cuesta agarrarle el ritmo al doble clic.
- Arrastra el archivo
.uf2a TFT_S3BOOT. La unidad desaparece y en su lugar aparece CIRCUITPY.
Copiar el código, los sushis y las librerías
Descarga el Project Bundle desde la guía original o desde el repositorio de Adafruit en GitHub. Descomprímelo y copia a la unidad CIRCUITPY:
- la carpeta
lib - la carpeta
sushi(fondo, cinta y un BMP por cada plato) code.pypanels.pysushi_viper.mpy

sushi_viper.mpy es el módulo Turbo, ya compilado con mpy-cross para el ESP32-S3. Su fuente en Python está en el mismo repositorio, por si quieres leerlo o recompilarlo.
Cómo está organizado code.py
El programa principal hace en Python lo que corre una sola vez y deja a sushi_viper todo lo que corre por cada píxel:
- Configura la pantalla. Libera cualquier display anterior, manda la secuencia de inicialización del panel por I²C a través del expansor de E/S y crea el
DotClockFramebuffercon los tiempos del panel elegido enpanels.py. - Dibuja el fondo una sola vez. Lee un BMP indexado de 8 bits y lo escribe rotado 90 grados en el framebuffer (
draw_rotated). La rotación es necesaria porque la barra físicamente es vertical y la escena se ve en horizontal. - Arma los "tiles".
load_tilescombina la tablilla de la cinta con cada plato de sushi, respetando el índice de color transparente, y guarda un tile opaco por plato en unbytearray. Hacer esta composición al arrancar significa que en el ciclo principal no hay que calcular transparencias. - El ciclo de animación. En cada cuadro actualiza el desplazamiento (
PARAM_SCROLL), recicla los platos que salieron de la pantalla con uno nuevo al azar, dibuja la cinta condraw_belt(la función Viper) y refresca el panel. Cada dos segundos imprime los cuadros por segundo y cuánto tardan el dibujo y el refresco, lo que te permite medir la ganancia de Turbo en tu propia placa.
Los parámetros que más vas a tocar están arriba del archivo: DISPLAY ("bar320x820" o "bar240x960"), PIXELS_PER_FRAME para la velocidad de la cinta (positivo hacia la derecha, negativo hacia la izquierda) y MAX_FPS si quieres limitar los cuadros por segundo.
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Sushi conveyor belt on a Qualia S3 with
either 320x820 or 240x960 bar display
graphics code in turbo (lib/sushi_viper.mpy).
"""
import os
import random
import time
import board
import busio
import displayio
import dotclockframebuffer
import sushi_viper
from panels import PANELS
# "bar320x820" (3.2") or "bar240x960" (3.7").
DISPLAY = "bar320x820"
# The TFT's I/O expander address None uses the board default (0x3F).
IO_EXPANDER_ADDRESS = None
# Belt speed. Positive moves the plates right in the landscape art,
# negative left
PIXELS_PER_FRAME = 1
# None runs at the panel's own refresh rate
MAX_FPS = None
ASSET_FOLDER = "/sushi"
# The palette index that is see-through in the plates and the belt slat.
TRANSPARENT_INDEX = 1
# How far the belt's top edge is below the top of the landscape background,
# in pixels.
BELT_TOP = {"bar320x820": 156, "bar240x960": 90}
# Slats repeat every SLAT_PITCH pixels along the belt. None puts them end to
# end at the slat's own width
SLAT_PITCH = None
# Each plate is centered on its slat
PLATE_OFFSET = (0, 0)
displayio.release_displays()
panel = PANELS[DISPLAY]
i2c = busio.I2C(board.SCL, board.SDA, frequency=panel["i2c_frequency"])
io_expander = dict(board.TFT_IO_EXPANDER)
if IO_EXPANDER_ADDRESS is not None:
io_expander["i2c_address"] = IO_EXPANDER_ADDRESS
dotclockframebuffer.ioexpander_send_init_sequence(
i2c, panel["init_sequence"], **io_expander
)
i2c.deinit()
framebuffer_settings = dict(board.TFT_PINS)
framebuffer_settings.update(panel["timings"])
framebuffer = dotclockframebuffer.DotClockFramebuffer(**framebuffer_settings)
screen_width, screen_height = framebuffer.width, framebuffer.height
first_pixel = framebuffer.first_pixel_offset // 2
row_stride = framebuffer.row_stride // 2
framebuffer_pixels = len(memoryview(framebuffer))
print(
f"{DISPLAY}: {screen_width}x{screen_height}, row stride {row_stride} px, "
f"first pixel {first_pixel}, panel refresh {framebuffer.refresh_rate} Hz"
)
load_start = time.monotonic()
background = sushi_viper.IndexedBMP(
"%s/sushi_background_%dx%d.bmp" % (ASSET_FOLDER, screen_height, screen_width)
)
if (background.width, background.height) != (screen_height, screen_width):
raise ValueError(
"background is %dx%d, needs to be %dx%d"
% (background.width, background.height, screen_height, screen_width)
)
background.draw_rotated(framebuffer, first_pixel, row_stride, framebuffer_pixels)
del background
def plate_number(name):
"""The number in sushi_<number>.bmp, or None for any other file."""
if name.startswith("sushi_") and name.endswith(".bmp") and name[6:-4].isdigit():
return int(name[6:-4])
return None
plate_paths = [
"%s/sushi_%d.bmp" % (ASSET_FOLDER, number)
for number in sorted(
plate_number(name)
for name in os.listdir(ASSET_FOLDER)
if plate_number(name) is not None
)
]
tiles, tile_width, tile_height, belt_x = sushi_viper.load_tiles(
framebuffer,
first_pixel,
row_stride,
screen_width,
BELT_TOP[DISPLAY],
ASSET_FOLDER + "/sushi_belt.bmp",
plate_paths,
TRANSPARENT_INDEX,
SLAT_PITCH,
PLATE_OFFSET,
)
print(
"%d plates on %dx%d tiles, loaded in %.2f s"
% (len(plate_paths), tile_width, tile_height, time.monotonic() - load_start)
)
# belt
slots, params, seen = sushi_viper.new(
screen_width,
screen_height,
first_pixel,
row_stride,
framebuffer_pixels,
len(plate_paths),
tile_width,
tile_height,
belt_x,
)
loop_length = sushi_viper.loop_length(params)
sushi_viper.recycle(
slots, params, seen, len(plate_paths), random.randrange, everything=True
)
print(f"{len(slots)} slots, belt loop {loop_length} px")
frame_ns = 1_000_000_000 // (MAX_FPS or framebuffer.refresh_rate)
next_frame_ns = time.monotonic_ns()
frames = draw_ns = flush_ns = 0
report_ns = next_frame_ns + 2_000_000_000
scroll = 0
while True:
params[sushi_viper.PARAM_SCROLL] = scroll
# swap in new plates for slots that have gone off screen
sushi_viper.recycle(slots, params, seen, len(plate_paths), random.randrange)
draw_start_ns = time.monotonic_ns()
sushi_viper.draw_belt(framebuffer, tiles, slots, params)
draw_end_ns = time.monotonic_ns()
framebuffer.refresh()
flush_end_ns = time.monotonic_ns()
scroll = (scroll + PIXELS_PER_FRAME) % loop_length
frames += 1
draw_ns += draw_end_ns - draw_start_ns
flush_ns += flush_end_ns - draw_end_ns
if flush_end_ns >= report_ns:
print(
"%d fps, draw %d us, refresh %d us"
% (frames // 2, draw_ns // frames // 1000, flush_ns // frames // 1000)
)
frames = draw_ns = flush_ns = 0
report_ns += 2_000_000_000
next_frame_ns += frame_ns
wait_ns = next_frame_ns - time.monotonic_ns()
if wait_ns > 0:
time.sleep(wait_ns / 1e9)
else:
next_frame_ns = time.monotonic_ns()
Fíjate en dos decisiones de diseño. El tiempo se mide con time.monotonic_ns() y enteros, no con flotantes, lo que evita la pérdida de precisión de los flotantes de CircuitPython en contadores largos. Y si un cuadro se atrasa, next_frame_ns se reinicia en vez de intentar "recuperar" cuadros perdidos, así la animación no acelera de golpe después de una pausa.
Por dentro de sushi_viper
El módulo Turbo trabaja sobre cuatro buffers que le entrega el programa principal: el propio framebuffer en RGB565, un bytearray con todos los tiles uno detrás de otro, un array('i') de "slots" que indica qué tile va en cada posición de la cinta, y el array('i') de parámetros. La clase IndexedBMP solo acepta BMP indexados de 8 bits sin compresión y convierte su paleta a RGB565 una vez; para la transparencia elige un color casi negro (0x0821) que no use ningún otro índice. Así, el ciclo interno solo compara un entero de 16 bits para saber si salta un píxel.
Montaje
- Conecta la pantalla al conector de 40 pines del Qualia S3: levanta con cuidado la traba negra, mete la cinta flexible del panel y vuelve a bajar la traba presionando suave para fijarla.

- Presiona suavemente la pantalla dentro de la tapa de la caja de sushi.

- Pasa el cable USB por la ranura trasera de la bola de arroz, conecta el Qualia y cierra la caja.
Las cintas flexibles de estos paneles son delicadas: no las dobles en ángulo vivo y verifica que entraron derechas y hasta el fondo antes de bajar la traba. Una cinta mal asentada típicamente se ve como franjas de colores o una pantalla en blanco aunque el código corra bien.
Pruebas: cómo saber si Turbo está haciendo su trabajo
Abre la consola serie (en Thonny, Mu o cualquier terminal) y mira lo que imprime el programa: primero el tamaño del panel y su frecuencia de refresco, después cuántos platos cargó y en cuánto tiempo, y luego cada dos segundos una línea como N fps, draw X us, refresh Y us.
- Si
drawes mucho menor que el tiempo de un cuadro, Viper está haciendo bien su parte y el límite pasa a ser el refresco del panel. - Si el programa falla al importar
sushi_viper, lo más probable es que tu CircuitPython sea anterior a 10.4.0-alpha.2 o que el.mpyno corresponda a la arquitectura de tu placa. - Si aparece
background is ..., needs to be ..., el BMP del fondo no corresponde al panel elegido enDISPLAY.
Variantes y mejoras
- Llevar la técnica a un ESP32-S3 con una TFT ST7789 por SPI. El patrón de este proyecto (fondo dibujado una vez, tiles precompuestos y un ciclo Viper que copia rectángulos) sirve tal cual en una pantalla SPI de 240 × 240 o 240 × 320. La diferencia es que en vez de un
dotclockframebufferusas unbytearraypropio como framebuffer y lo envías por SPI. Ahí el cuello de botella pasa a ser el bus: a 40 MHz, un cuadro completo de 240 × 240 en RGB565 tarda unos 23 ms, así que conviene enviar solo la franja de la cinta que cambia. - Reutilizar
overlay_rectpara otros juegos. La función que copia un rectángulo RGB565 saltándose un color transparente es la base de cualquier motor de sprites. Con ella puedes hacer un marcador de puntaje, un juego tipo runner o una animación de logo para una vitrina. - Velocidad controlada por un potenciómetro. Lee un potenciómetro con el ADC y cambia
PIXELS_PER_FRAMEen el ciclo principal. Es una buena forma de ver en vivo cómo cambia la línea de fps a medida que la cinta acelera.
Personalización para Chile
El Qualia ESP32-S3 y las pantallas de barra RGB son productos de Adafruit que difícilmente vas a encontrar en una tienda local. Para aprender la técnica Viper en Chile, en el catálogo de MechatronicStore busca:
- Placa de desarrollo ESP32-S3 con PSRAM: el mismo chip del Qualia, que es lo que importa para el
.mpycompilado para Xtensa LX7. Revisa en circuitpython.org que exista firmware para tu placa. - Pantalla TFT ST7789 o ILI9341 por SPI: para la variante con SPI descrita arriba. No reemplaza a la barra RGB de 40 pines (es otra interfaz), pero sirve para la misma idea de animación.
- Cable USB-C de datos: para cargar CircuitPython y alimentar la placa.
- Filamento PLA: si quieres imprimir la caja de sushi.
Recursos
- Tutorial original (inglés): CircuitPython Turbo Sushi Conveyor Belt, de Liz Clark en Adafruit Learning System. Esta versión está basada en él.
- Código del proyecto (Project Bundle y fuente de
sushi_viper): Adafruit_Learning_System_Guides en GitHub - Firmware CircuitPython para el Qualia S3: circuitpython.org
- Archivos STL de la caja: en la sección 3D Printing de la guía original y en Printables.
- Explicación de Viper: la guía PixelDust Digital Sand for CircuitPython de Adafruit Learning System.
Versión chilena con alternativas del catálogo local.




