Come ho creato l’arte generativa con Python che 10000 crediti DALL-E non potevano comprare

Creazione arte generativa con Python oltre 10000 crediti DALL-E non sufficienti

Python e Pillow: Come codificare qualcosa che DALL-E non può fare

In questo post del blog, mostrerò alcune delle opere generative che ho creato utilizzando il linguaggio di programmazione Python, specificamente utilizzando le librerie Pillow e Torch. L’ispirazione per le mie opere d’arte è venuta dalle composizioni visive di Roman Haubenstock-Ramati, un compositore musicale e artista visivo austriaco.

All’inizio del 2021 stavo navigando frequentemente su Catawiki perché volevo comprare un po’ di arte per decorare il mio ufficio a casa. Quando sono incappato nelle creazioni di Haubenstock-Ramati su Catawiki all’inizio del 2021, sono stato immediatamente affascinato dalla natura intricata e bella della sua arte parametrica. Volevo fare qualcosa di creativo con le mie abilità di programmazione da un po’ di tempo, così sono stato ispirato a sviluppare un codice che potesse produrre output simili. L’immagine qui sotto è un esempio di una delle immagini che mi hanno ispirato, creata da Roman Haubenstock-Ramati.

Konstellationen, 1970/1971 di Roman Haubenstock-Ramati

Dopo il rilascio di Dall-E 2 nell’aprile 2022, ho esplorato l’uso del modello per generare opere d’arte che dovrebbero assomigliare al lavoro di Haubenstock-Ramati. Chiedere al modello di fare ciò è un argomento controverso poiché ci sono preoccupazioni valide sulla capacità dei modelli di intelligenza artificiale di produrre output così simili al lavoro di un artista che l’output possa essere considerato come una violazione di copyright del lavoro originale. Questa discussione va oltre lo scopo di questo post del blog, ma voglio chiarire che le indicazioni che ho dato a Dall-E non erano intese a produrre copie esatte del lavoro di Haubenstock-Ramati o a svalutare le sue opere. Lo stesso vale per il codice che ho scritto, non sono intesi a distribuire copie del suo lavoro ma semplicemente una dimostrazione di come è possibile utilizzare Python per creare composizioni geometriche visive.

Il risultato di DALL-E era interessante, ma non catturava del tutto l’essenza delle sue opere originali. L’output mancava dei precisi vincoli e delle intricatezze presenti nell’arte di Haubenstock-Ramati. Ho provato molte varianti di indicazioni ma non sono mai riuscito ad avvicinarmi a ciò che volevo.

Alcuni degli output generati da Dall-E dati la mia indicazione: “Crea una composizione pittorica nello stile di Roman Haubenstock-Ramati che incorpori elementi di notazione grafica e composizione musicale sperimentale. Il dipinto dovrebbe essere prevalentemente in bianco e nero con linee audaci e forme geometriche, e dovrebbe includere un motivo centrale che rappresenta il tema del pezzo.”

Nel tentativo di semplificare il processo, ho posto una richiesta più semplice a Dall-E: “Disegna una linea verticale collegata a un rettangolo, collega un quadrato alla linea e collega il quadrato con un’altra linea verticale a un altro rettangolo, infine collega il rettangolo a un cerchio con un’altra linea verticale”. Sorprendentemente, i risultati sono stati inaspettati. Nonostante la semplicità dell’indicazione, Dall-E ha faticato a comprendere le relazioni intese tra le forme, producendo esiti imprevisti.

Immagini generate da Dall-E dati l'indicazione: “Disegna una linea verticale collegata a un rettangolo, collega un quadrato alla linea e collega il quadrato con un'altra linea verticale a un altro rettangolo, infine collega il rettangolo a un cerchio con un'altra linea verticale.”

È diventato chiaro per me che Dall-E non ha la capacità di elaborare prompt geometricamente vincolati, ho provato un prompt ancora più semplice: “Crea un disegno che mostri solo due linee ortogonali”. Anche questo si è rivelato troppo difficile.

Questa incapacità di Dall-E mi ha sorpreso, ma quando si pensa a come funziona un modello come Dall-E, questo non è sorprendente. Si basa sulla diffusione latente, che è intrinsecamente un processo rumoroso e non ottimizzato per prompt esatti basati su vincoli.

Successivamente, mostrerò le immagini che ho generato e parlerò in modo più dettagliato su come codificare qualcosa di simile.

Singolo esempio di un'immagine generata dal mio codice.
Un gif che mostra diverse immagini create dal mio codice, mostrando la diversità di immagini create dagli stessi parametri.

Ho creato queste immagini utilizzando Python e Pillow, prive di qualsiasi apprendimento automatico. Le immagini prodotte dal mio codice presentano elementi di casualità introdotti tramite Torch, un pacchetto versatile che ho utilizzato per la sua familiarità e comodità. È normalmente un pacchetto utilizzato nell’apprendimento automatico (ML). Ma ancora una volta queste immagini non sono realizzate utilizzando l’apprendimento automatico (ML).

Potresti chiederti da dove provenga la diversità delle immagini, personalmente adoro come il mio codice sia in grado di generare immagini che danno una sensazione simile ma sono tutte così diverse se guardate da vicino. La diversità delle uscite era una caratteristica essenziale da raggiungere. La varianza delle immagini prodotte dal mio codice deriva da un utilizzo intricato di variabili casuali. Una variabile casuale, nel campo della teoria delle probabilità e delle statistiche, è una variabile i cui possibili valori sono gli esiti di un fenomeno casuale.

Ora descriverò il processo di generazione delle immagini realizzate dal mio codice e mostrerò alcuni esempi in Python di come questo processo di generazione appare da un punto di vista generale.

Possiamo dividere il processo di generazione in 3 fasi.

  • Fase 1: Viene generato il pezzo centrale. Ciò avviene campionando un rettangolo, una linea, un rettangolo, un quadrato, una linea e un cerchio. Questi vengono posizionati in una posizione fissa e le dimensioni delle forme sono determinate da variabili casuali.
  • Fase 2: Vengono campionati tre gruppi con linee adiacenti da tre diverse distribuzioni. In ogni gruppo vengono posizionate un numero di linee verticali con vari punti di inizio e fine.
  • Fase 3: Vengono campionati cerchi e rettangoli e disegnati all’interno dei gruppi di linee.
Gif che mostra il processo di generazione passo passo di un'immagine singola.

Fase 1

Per capire il ruolo delle variabili casuali nel mio codice, considera il primo passo nel nostro processo di creazione di immagini: la formazione di un rettangolo in stile ritratto, caratterizzato da un’altezza maggiore rispetto alla larghezza. Questo rettangolo, sebbene apparentemente semplice, è un’incarnazione delle variabili casuali in azione.

Un rettangolo può essere scomposto in quattro elementi principali: una coordinata x e y di inizio e una coordinata x e y di fine. Ora, questi punti, quando scelti da una specifica distribuzione, si trasformano in variabili casuali. Ma come decidiamo l’intervallo di questi punti, o più specificamente, la distribuzione da cui provengono? La risposta si trova in una delle distribuzioni più comuni e cruciali nella statistica: la distribuzione normale.

Definita da due parametri – la media (μ) e la deviazione standard (σ), la distribuzione normale svolge un ruolo centrale nel nostro processo di generazione di immagini. La media, μ, indica il centro della distribuzione, agendo quindi come il punto intorno al quale i valori delle nostre variabili casuali gravitano. La deviazione standard, σ, quantifica il grado di dispersione nella distribuzione. Decide l’intervallo di valori che le variabili casuali potrebbero potenzialmente assumere. In sostanza, una deviazione standard maggiore comporterebbe una maggiore diversità nelle immagini create.

import torchcanvas_height = 1000canvas_width = 1500#ciclo per mostrare diversi valoriPer i in range(5):    #crea una distribuzione normale da cui campionare    start_y_dist = torch.distributions.Normal(canvas_height * 0.8, canvas_height * 0.05)    #campiona dalla distribuzione    start_y = int(start_y_dist.sample())        #crea una distribuzione normale da cui campionare l'altezza    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 è fisso perché è centrato    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

Il campionamento di un quadrato assomiglia molto, dobbiamo solo campionare l’altezza o la larghezza poiché sono uguali. Il campionamento di un cerchio è ancora più semplice poiché dobbiamo solo campionare il raggio.

Disegnare un rettangolo in Python è un processo diretto, specialmente quando si utilizza la libreria Pillow. Ecco come puoi farlo:

from PIL import Image, ImageDraw# Crea una nuova immagine con uno sfondo bianco# Ciclo per disegnare i rettangolifor i in range(5):    img = Image.new('RGB', (canvas_width, canvas_height), 'white')    draw = ImageDraw.Draw(img)    # Creazione di distribuzioni normali da cui campionare    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    # Disegno del rettangolo    draw.rectangle([(start_x, start_y), (end_x, end_y)], outline='black')    img.show()

Passaggio 2

Nel contesto delle linee verticali in queste immagini, consideriamo tre variabili casuali, ovvero:

  1. La coordinata y di inizio della linea (y_start)
  2. La coordinata y di fine della linea (y_end)
  3. La coordinata x della linea (x)

Dato che stiamo trattando linee verticali, è sufficiente campionare una sola coordinata x per ogni linea. La larghezza della linea è costante, controllata dalle dimensioni del canvas.

È stato necessario applicare una logica aggiuntiva per garantire che le linee non si intersechino. Per fare ciò, è necessario considerare l’immagine come una griglia e tenere traccia delle posizioni occupate. Per semplicità, ignoriamo questo aspetto.

Ecco un esempio di come appare in Python.

import torchfrom PIL import Image, ImageDraw# Imposta la dimensione del canvascanvas_size = 1000# Numero di lineenum_lines = 10# Crea le distribuzioni per le coordinate y di inizio e fine e la coordinata xy_start_distribution = torch.distributions.Normal(canvas_size / 2, canvas_size / 4)y_end_distribution = torch.distributions.Normal(canvas_size / 2, canvas_size / 4)x_distribution = torch.distributions.Normal(canvas_size / 2, canvas_size / 4)# Campiona dalle distribuzioni per ogni lineay_start_points = y_start_distribution.sample((num_lines,))y_end_points = y_end_distribution.sample((num_lines,))x_points = x_distribution.sample((num_lines,))# Crea un canvas biancoimage = Image.new('RGB', (canvas_size, canvas_size), 'white')draw = ImageDraw.Draw(image)# Disegna le lineefor i in range(num_lines):    draw.line([(x_points[i], y_start_points[i]), (x_points[i], y_end_points[i])], fill='black')# Mostra l'immagineimage.show()

Tuttavia, ciò fornisce solo delle linee. Un’altra parte del cluster sono i cerchi alla fine delle linee, che ho chiamato cerchi adiacenti. Le variabili casuali determinano anche il loro processo. Inizialmente, il fatto che ci sia un cerchio adiacente viene campionato da una distribuzione di Bernoulli, e la posizione (sinistra, centro, destra) della forma viene campionata da una distribuzione uniforme.

Un cerchio può essere definito interamente da un singolo parametro: il suo raggio. Possiamo considerare la lunghezza di una linea come una condizione che influenza il raggio del cerchio. Questo forma un modello di probabilità condizionale in cui il raggio (R) del cerchio dipende dalla lunghezza della linea (L). Utilizziamo una distribuzione gaussiana condizionale. La media (μ) di questa distribuzione è una funzione della radice quadrata della lunghezza della linea, mentre la deviazione standard (σ) è una costante.

Inizialmente suggeriamo che il raggio R, dato la lunghezza della linea L, segua una distribuzione normale. Questo è indicato come R | L ~ N(μ(L), σ²), dove N è la distribuzione normale (gaussiana) e σ è la deviazione standard.

Tuttavia, questo presenta un piccolo problema: la distribuzione normale include la possibilità di campionare un valore negativo. Questo risultato non è fisicamente possibile nel nostro scenario, poiché un raggio non può essere negativo.

Per ovviare a questo problema, possiamo utilizzare la distribuzione seminormale. Questa distribuzione, molto simile alla distribuzione normale, è definita da un parametro di scala σ, ma crucialmente è vincolata a valori non negativi. Il raggio dato la lunghezza della linea segue una distribuzione seminormale: R | L ~ HN(σ), dove HN indica la distribuzione seminormale. In questo modo, σ è determinato dalla media desiderata come σ = √(2L) / √(2/π), garantendo che tutti i raggi campionati siano non negativi e che la media della distribuzione sia √(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()

Passaggio 3

Il passaggio 3 nel nostro processo è una combinazione di elementi dai Passaggi 1 e 2. Nel Passaggio 1, abbiamo affrontato il compito di campionare e disegnare rettangoli in posizioni fisse. Nel Passaggio 2, abbiamo imparato come utilizzare la distribuzione normale per disegnare linee su una parte della tua tela. Inoltre, abbiamo acquisito conoscenze su come campionare e disegnare cerchi.

Mentre passiamo al Passaggio 3, riutilizzeremo le tecniche dei passaggi precedenti. Il nostro obiettivo è distribuire quadrati e cerchi armoniosamente intorno alle linee che abbiamo campionato in precedenza. La distribuzione normale, ancora una volta, sarà utile per questa attività.

Riutilizzeremo i parametri utilizzati per creare cluster di linee. Tuttavia, per migliorare l’aspetto visivo e evitare sovrapposizioni, introduciamo un po’ di rumore nei valori di media (mu) e deviazione standard.

In questo passaggio, invece di posizionare linee, il nostro compito è posizionare rettangoli e cerchi campionati. Ti incoraggio a sperimentare con queste tecniche e provare ad aggiungere cerchi e rettangoli al tuo cluster di linee.

In questo post del blog, ho analizzato e semplificato i processi alla base del mio codice per consentire una comprensione più approfondita di come funziona. Ho mostrato la difficoltà per modelli di intelligenza artificiale generativa come Dall-E nel seguire precise restrizioni.

Scrivere il codice che ha prodotto queste immagini è stata un’ottima esperienza per me. Vedere l’immagine progredire con ogni riga di codice che ho scritto è stato fantastico da osservare. Spero che questo post del blog abbia suscitato il tuo interesse nell’intersezione tra arte e codifica. Ti incoraggio a utilizzare le tue competenze di codifica e a dare vita alla tua immaginazione usando il codice. Non c’è bisogno di esaurire i tuoi crediti Dall-E; il potere di creare è proprio a portata di mano.