Cómo creé arte generativo con Python que 10000 créditos de DALL-E no podrían comprar
Creé arte generativo con Python que 10000 créditos de DALL-E no podrían comprar
Python y Pillow: Cómo codificar algo que DALL-E no puede hacer
En esta publicación de blog, mostraré algunas de las obras de arte generativa que creé utilizando el lenguaje de programación Python, específicamente utilizando las bibliotecas Pillow y Torch. La inspiración para mi obra de arte provino de las composiciones visuales de Roman Haubenstock-Ramati, un compositor de música y artista visual austriaco.
A principios de 2021, navegaba con frecuencia por Catawiki porque quería comprar algunas obras de arte para decorar mi oficina en casa. Cuando me encontré con las creaciones de Haubenstock-Ramati en Catawiki a principios de 2021, quedé inmediatamente cautivado por la naturaleza intrincada y hermosa de su arte paramétrico. Había querido hacer algo creativo con mis habilidades de programación durante un tiempo, así que me inspiré para desarrollar código que pudiera producir una salida similar. La imagen a continuación es un ejemplo de una de las imágenes que me inspiraron, creada por Roman Haubenstock-Ramati.

Después del lanzamiento de Dall-E 2 en abril de 2022, exploré el uso del modelo para generar obras de arte que se asemejen al trabajo de Haubenstock-Ramati. Pedirle al modelo que haga esto es un tema controvertido, ya que existen preocupaciones válidas sobre la capacidad de los modelos de IA para producir una salida que sea tan similar al trabajo de un artista que se pueda considerar como una infracción de derechos de autor sobre la obra original. Esta discusión está más allá del alcance de esta publicación de blog, pero quiero dejar claro que las indicaciones que alimenté a Dall-E no tenían la intención de producir copias exactas del trabajo de Haubenstock-Ramati ni de devaluar sus obras. Lo mismo ocurre con el código que he escrito, no tienen la intención de distribuir copias de su trabajo, sino simplemente demostrar cómo se puede usar Python para crear composiciones geométricas visuales.
La salida de DALL-E fue interesante, pero no capturó del todo la esencia de sus piezas originales. La salida carecía de las restricciones precisas e intrincadas presentes en el arte de Haubenstock-Ramati. Probé muchas variaciones de indicaciones, pero nunca pude acercarme a lo que quería.
- Herramientas críticas para la IA ética y explicativa
- Gradient Boosting de la teoría a la práctica (Parte 2)
- 50 principales preguntas de entrevista de IA con respuestas

En un intento de simplificar el proceso, le hice una solicitud más simple a Dall-E: “Dibuja una línea vertical conectada a un rectángulo, conecta un cuadrado a la línea y conecta el cuadrado con otra línea vertical a otro rectángulo, y finalmente conecta el rectángulo con un círculo con otra línea vertical”. Sorprendentemente, los resultados fueron inesperados. A pesar de la simplicidad de la indicación, Dall-E tuvo dificultades para comprender las relaciones previstas entre las formas, dando lugar a resultados inesperados.

Me quedó claro que Dall-E no tiene la capacidad de procesar indicaciones geométricamente restringidas. Intenté una indicación aún más simple: “Crea un dibujo que solo muestre dos líneas ortogonales”. Esto también resultó ser demasiado difícil.

Esta incapacidad de Dall-E me sorprendió, pero al pensar en cómo funciona un modelo como Dall-E, esto no es sorprendente. Está basado en difusión latente, que es inherentemente un proceso ruidoso y no está optimizado para indicaciones exactas basadas en restricciones.
A continuación, mostraré las imágenes que generé y hablaré en más detalle sobre cómo codificar algo así.


Hice estas imágenes usando Python y Pillow, sin ninguna inteligencia artificial. Las imágenes producidas por mi código tienen elementos de aleatoriedad introducidos a través de Torch, un paquete versátil que utilicé por su familiaridad y conveniencia. Normalmente es un paquete utilizado en Aprendizaje Automático (ML). Pero nuevamente, estas imágenes no se crearon utilizando Aprendizaje Automático (ML).
Tal vez te preguntes de dónde viene la diversidad de las imágenes. Personalmente, me encanta cómo mi código es capaz de generar imágenes que transmiten una vibra similar pero que son todas diferentes si las observas de cerca. La diversidad en las salidas es una característica esencial que se logró. La variación de las imágenes que produce mi código se deriva de un uso intrincado de variables aleatorias. Una variable aleatoria, en el ámbito de la teoría de la probabilidad y las estadísticas, es una variable cuyos posibles valores son resultados de un fenómeno aleatorio.
Ahora describiré el proceso de generación de las imágenes creadas por mi código y mostraré algunos ejemplos en Python de cómo se ve este proceso de generación desde una perspectiva de alto nivel.
Podemos dividir el proceso de generación en 3 pasos.
- Paso 1: Se genera la pieza central. Esto se hace muestreando un rectángulo, una línea, un rectángulo, un cuadrado, una línea y un círculo. Estos se colocan en una posición fija y el tamaño de las formas se determina mediante variables aleatorias.
- Paso 2: Se muestrean tres grupos de líneas y adyacentes a partir de tres distribuciones diferentes. En cada grupo se colocan varias líneas verticales con diversos puntos de inicio y finalización.
- Paso 3: Se muestrean y dibujan círculos y rectángulos dentro de los grupos de líneas.

Paso 1
Para entender el papel de las variables aleatorias en mi código, considera el primer paso en nuestro proceso de creación de imágenes: formar un rectángulo al estilo de un retrato, caracterizado por su mayor altura que su anchura. Este rectángulo, aunque parece sencillo, es una encarnación de las variables aleatorias en acción.
Un rectángulo se puede dividir en cuatro elementos principales: una coordenada de inicio en x y y, y una coordenada de finalización en x y y. Ahora, estos puntos, cuando se eligen de una distribución específica, se convierten en variables aleatorias. Pero ¿cómo decidimos el rango de estos puntos, o más específicamente, la distribución de la que provienen? La respuesta se encuentra en una de las distribuciones más comunes y cruciales en estadísticas: la Distribución Normal.
Definida por dos parámetros — la media (μ) y la desviación estándar (σ), la Distribución Normal desempeña un papel fundamental en nuestro proceso de generación de imágenes. La media, μ, representa el centro de la distribución, actuando como el punto alrededor del cual los valores de nuestras variables aleatorias gravitan. La desviación estándar, σ, cuantifica el grado de dispersión en la distribución. Decide el rango de valores que las variables aleatorias podrían tomar potencialmente. En esencia, una desviación estándar más grande resultaría en una mayor diversidad en las imágenes creadas.
import torchcanvas_height = 1000canvas_width = 1500#bucle para mostrar diferentes valoresfor i in range(5): #crear una distribución normal para muestrear desde start_y_dist = torch.distributions.Normal(canvas_height * 0.8, canvas_height * 0.05) #muestrear desde la distribución start_y = int(start_y_dist.sample()) #crear una distribución normal para muestrear la altura desde height_dist = torch.distributions.Normal(canvas_height * 0.2, canvas_height * 0.05) height = int(height_dist.sample()) end_y = start_y + height #start_x es fijo debido a que esto está centrado start_x = canvas_width // 2 width_dist = torch.distributions.Normal(height * 0.5, height * 0.1) width = int(width_dist.sample()) end_x = start_x + width print(f"start_x: {start_x}, end_x: {end_x}, start_y: {start_y}, end_y: {end_y}, width: {width}, height: {height}")
start_x: 750, end_x: 942, start_y: 795, end_y: 1101, width: 192, height: 306start_x: 750, end_x: 835, start_y: 838, end_y: 1023, width: 85, height: 185start_x: 750, end_x: 871, start_y: 861, end_y: 1061, width: 121, height: 200start_x: 750, end_x: 863, start_y: 728, end_y: 962, width: 113, height: 234start_x: 750, end_x: 853, start_y: 812, end_y: 986, width: 103, height: 174
Muestrear un cuadrado se ve muy similar, solo tenemos que muestrear la altura o el ancho ya que son iguales. Muestrear un círculo es aún más fácil ya que solo tenemos que muestrear el radio.
Dibujar un rectángulo en Python es un proceso sencillo, especialmente al utilizar la biblioteca Pillow. Aquí te mostramos cómo hacerlo:
from PIL import Image, ImageDraw# Crear una nueva imagen con fondo blanco# Bucle para dibujar rectángulosfor i in range(5): img = Image.new('RGB', (canvas_width, canvas_height), 'white') draw = ImageDraw.Draw(img) # Crear distribuciones normales para muestrear desde start_y_dist = torch.distributions.Normal(canvas_height * 0.8, canvas_height * 0.05) start_y = int(start_y_dist.sample()) height_dist = torch.distributions.Normal(canvas_height * 0.2, canvas_height * 0.05) height = int(height_dist.sample()) end_y = start_y + height start_x = canvas_width // 2 width_dist = torch.distributions.Normal(height * 0.5, height * 0.1) width = int(width_dist.sample()) end_x = start_x + width # Dibujar el rectángulo draw.rectangle([(start_x, start_y), (end_x, end_y)], outline='black') img.show()
Paso 2
En el contexto de las líneas verticales en estas imágenes, consideramos tres variables aleatorias, a saber:
- La coordenada y inicial de la línea (y_inicio)
- La coordenada y final de la línea (y_fin)
- La coordenada x de la línea (x)
Dado que estamos tratando con líneas verticales, solo se necesita muestrear una coordenada x para cada línea. El ancho de la línea es constante, controlado por el tamaño del lienzo.
Se necesitó algo de lógica adicional para asegurarse de que las líneas no se intersectaran. Para hacer esto básicamente, necesitamos tener en cuenta la imagen como una cuadrícula y hacer un seguimiento de las posiciones ocupadas. Vamos a ignorar eso por simplicidad.
Aquí tienes un ejemplo de cómo se ve esto en Python.
import torchfrom PIL import Image, ImageDraw# Establecer el tamaño del lienzo tamaño_del_lienzo = 1000# Número de líneasnum_lineas = 10# Crear distribuciones para las coordenadas y de inicio y fin y la coordenada xcoordenadas_y_inicio = torch.distributions.Normal(tamaño_del_lienzo / 2, tamaño_del_lienzo / 4)coordenadas_y_fin = torch.distributions.Normal(tamaño_del_lienzo / 2, tamaño_del_lienzo / 4)coordenadas_x = torch.distributions.Normal(tamaño_del_lienzo / 2, tamaño_del_lienzo / 4)# Muestrear desde las distribuciones para cada líneapuntos_y_inicio = coordenadas_y_inicio.sample((num_lineas,))puntos_y_fin = coordenadas_y_fin.sample((num_lineas,))puntos_x = coordenadas_x.sample((num_lineas,))# Crear un lienzo blancoinagen = Image.new('RGB', (tamaño_del_lienzo, tamaño_del_lienzo), 'white')dibujo = ImageDraw.Draw(imagen)# Dibujar las líneasfor i in range(num_lineas): dibujo.line([(puntos_x[i], puntos_y_inicio[i]), (puntos_x[i], puntos_y_fin[i])], fill='black')# Mostrar la imagenimagen.show()
Esto, sin embargo, solo te da líneas. Otra parte del conjunto son los círculos al final de las líneas, a los que llamé círculos adyacentes. Las variables aleatorias también determinan su proceso. Primero, la probabilidad de que haya un círculo adyacente se muestrea de una distribución de Bernoulli, y la posición (izquierda, media, derecha) de la forma se muestrea de una distribución uniforme.
Un círculo puede ser definido completamente por un solo parámetro: su radio. Podemos considerar la longitud de una línea como una condición que influye en el radio del círculo. Esto forma un modelo de probabilidad condicional donde el radio (R) del círculo depende de la longitud de la línea (L). Usamos una distribución gaussiana condicional. La media (μ) de esta distribución es una función de la raíz cuadrada de la longitud de la línea, mientras que la desviación estándar (σ) es una constante.
Inicialmente sugerimos que el radio R, dado la longitud de la línea L, sigue una distribución normal. Esto se denota como R | L ~ N(μ(L), σ²), donde N es la distribución normal (gaussiana) y σ es la desviación estándar.
Sin embargo, esto tiene un pequeño problema: la distribución normal incluye la posibilidad de muestrear un valor negativo. Este resultado no es físicamente posible en nuestro escenario, ya que un radio no puede ser negativo.
Para evitar este problema, podemos usar la distribución seminormal. Esta distribución, al igual que la distribución normal, está definida por un parámetro de escala σ, pero crucialmente, está limitada a valores no negativos. El radio dado la longitud de la línea sigue una distribución seminormal: R | L ~ HN(σ), donde HN denota la distribución seminormal. De esta manera, σ está determinado por la media deseada como σ = √(2L) / √(2/π), asegurando que todos los radios muestreados sean no negativos y que la media de la distribución sea √(2L).
from PIL import Image, ImageDrawimport numpy as npimport torch# Define your line lengthL = 3000# Calculate the desired mean for the half-normal distributionmu = np.sqrt(L * 2)# Calculate the scale parameter that gives the desired meanscale = mu / np.sqrt(2 / np.pi)# Create a half-normal distribution with the calculated scale parameterdist = torch.distributions.HalfNormal(scale / 3)# Sample and draw multiple circlesfor _ in range(10): # Create a new image with white background img_size = (2000, 2000) img = Image.new('RGB', img_size, (255, 255, 255)) draw = ImageDraw.Draw(img) # Define the center of the circles start_x = img_size[0] // 2 start_y = img_size[1] // 2 # Sample a radius from the distribution r = int(dist.sample()) print(f"Sampled radius: {r}") # Define the bounding box for the circle bbox = [start_x - r, start_y - r, start_x + r, start_y + r] # Draw the circle onto the image draw.ellipse(bbox, outline ='black',fill=(0, 0, 0)) # Display the image img.show()
Paso 3
El paso 3 en nuestro proceso es una combinación de elementos de los Pasos 1 y 2. En el Paso 1, abordamos la tarea de muestrear y dibujar rectángulos en posiciones establecidas. En el Paso 2, aprendimos cómo usar la distribución normal para dibujar líneas en una parte de tu lienzo. Además, adquirimos conocimiento sobre cómo muestrear y dibujar círculos.
A medida que avanzamos al Paso 3, vamos a reutilizar las técnicas de los pasos anteriores. Nuestro objetivo es distribuir cuadrados y círculos de manera armoniosa alrededor de las líneas que muestreamos anteriormente. La distribución normal, una vez más, será útil para esta tarea.
Vamos a reutilizar los parámetros utilizados para crear conjuntos de líneas. Sin embargo, para mejorar el atractivo visual y evitar superposiciones, introducimos algo de ruido en los valores de la media (mu) y desviación estándar.
En este paso, en lugar de posicionar líneas, nuestra tarea es colocar rectángulos y círculos muestreados. Te animo a jugar con estas técnicas y tratar de agregar círculos y rectángulos a tu conjunto de líneas.
En esta publicación de blog, he disecado y simplificado los procesos subyacentes de mi código para permitir una comprensión más profunda de cómo funciona. He mostrado la dificultad para que los modelos de IA generativos como Dall-E sigan restricciones precisas.
La escritura del código que produjo estas imágenes fue una gran experiencia para mí. Ver el progreso de la imagen con cada línea de código que escribí fue muy emocionante presenciar. Espero que esta publicación de blog haya despertado tu interés en la intersección entre el arte y la programación. Te animo a usar tus habilidades de programación y dar vida a tu imaginación usando código. No es necesario agotar tus créditos de Dall-E; el poder de crear está justo al alcance de tus manos.