Fabian Wohlgemuth

digital creative. tool wizard.

wohfab

  • “Nested Squares” – Generative Art mit Python Cairo (PyCairo)

    Nach meinen ersten Schritten mit Generative Art mit Python Pillow, habe ich mir einmal angeschaut, wie ich von Pixelgrafiken weg und hin zu Vektorgrafiken kommen kann und bin dabei auf PyCairo gestoßen.

    Hier kann man in wenigen Schritten Vektorgrafiken erzeugen.

    Gar nicht so übel für den Anfang. Wie ist der Weg dahin? Wir brauchen wieder wenige Module, um das ganze zu erschaffen. pycairo und random.

    import cairo
    import random

    Und schon kann’s losgehen. Wir erstellen eine .svg Datei mit dem gewünschten Dateinamen und den gewünschten Maßen. Hier erstelle ich nested_squares.svg mit den Maßen 512x512. Cairo arbeitet mit sogenannten surfaces und contexts. Die Datei, die wir erstellen, ist ein surface, also eine Oberfläche. Auf dieser Oberfläche möchten wir malen. Dies tun wir mit context.

    with cairo.SVGSurface("nested_squares.svg", 512, 512) as surface:
        context = cairo.Context(surface)

    Und was brauchen wir zum Malen? Wir geben uns ersteinmal eine Hintergrundfarbe. Farben in pycairo geben wir im sogenannten XYZColor Format an. Heißt, wir nutzen RGB, aber mit einer Skala von 0 bis 1 für die einzelnen Farben. Das bedeutet, Weiß ist (1, 1, 1) und Schwarz (0, 0, 0). Wie schon in Python Pillow, wird der Hintergrund ganz einfach als rectangle über die gesamte Context-Größe erstellt. Zuletzt füllen wir das gemalte Rechteck mit der gesetzten Farbe. (Das alles findet im with Kontext statt; also immer schön an die Einrückung denken!) Dies sieht dann so aus:

    context.set_source_rgb(0, 0, 0)
    context.rectangle(0, 0, 512, 512)
    context.fill()

    WOW! Was ein Hingucker! Schnell was drüberwerfen und nochmal schauen. Farbe verändert, gleiche Rechteck-Größe, aber diesmal nur die Umrandung mit stroke().

    context.set_source_rgb(1, 0.1, 0.4)
    context.rectangle(0, 0, 512, 512)
    context.stroke()

    Viel besser. Wie teilen wir das jetzt in ein Grid auf? Lass uns ein Schachbrett versuchen. Dazu iterieren wir über 8 Zeilen und 8 Spalten und möchten jeweils wieder ein Rechteck malen. 8er-Grid ist einfach. Wir nehmen die Größe des Context (512) und teilen durch 8. Dazu fahren wir auf unserem Context mit .move_to() an einen bestimmten Punkt. In Iteration 1 ist dies (0, 0), da unsere range(8) Listen bei 0 beginnen. In den weiteren Iterationen bewegen die sich dann entsprechend immer ein achtel der Context-Größe nach rechts, bis das Ende einer Zeile erreicht ist. Dann geht’s über die y-Schleife eine Zeile nach unten.

    Was machen wir nun, wenn wir an dem Punkt angekommen sind? Wir erstellen wieder ein Rechteck mit .rectangle. Dieses Mal beginnen wir aber nicht fix bei (0,0) sondern bei genau dem Punkt, zu dem wir mit .move_to gegangen sind. Mit context.get_current_point() kriegen wir diese Koordinaten raus. Der * davor ist eine sehr praktische Python-Funktion, mit der Tupel adhoc entpackt werden können. Statt also context.get_current_point()[0] für die x-Koordinate rauszufinden, können wir mit dem * direkt x und y des current_point in die .rectangle Funktion einbetten. Wie groß soll das Rechteck sein? Natürlich unserem Grid angepasst ein achtel der Gesamtgröße. Dann, wie vorhin auch schon, wird mit .stroke() gezeichnet.

    for x in range(8):
        for y in range(8):
            context.move_to(x * 512/8, y * 512/8)
            context.rectangle(*context.get_current_point(), 512/8, 512/8)
            context.stroke()

    Fantastisch. Jetzt nur noch die schönen Muster, oder? Hier fing dann mein Kopfzerbrechen an. Meine Inspiration, dieses Muster nachzubauen, kam aus einem YouTube-Video von Coding Cassowary. Coding Cassowary benutzt aber nicht PyCairo sondern turtle. Der feine aber entscheidende Unterschied? PyCairo zeichnet Rechtecke vom Ankerpunkt oben links (Koordinate (0,0)). Turtle hingegen hat den Ankerpunkt eines Rechtecks zentriert in der Mitte. Letzteres ist für diese Aufgabe ziemlich von Vorteil. Man wackelt in die Mitte eines Schachfelds, bewegt sich ein bisschen in irgendeine Richtung und zeichnet ein neues Rechteck, was etwas kleiner ist, als das Schachfeld. Mit PyCairo rechnen wir aber immer vom oberen linken Punkt, weshalb ab der eigentlich spannenden Stelle das Tutorial für mich leider nutzlos wurde. Was tun wir also?

    Zuerst müssen wir in der obigen Schleife noch eine Zeile hinzufügen, die nach jedem .move_to die Koordination speichert. Da müssen wir nämlich gleich drauf zugreifen. Der Rest des Code-Schnipsels bleibt gleich.

    ...
    context.move_to(x * 512/8, y * 512/8)
    current_point = context.get_current_point()
    ...

    Jetzt wird’s spannend! Zuerst möchten wir den Zufalls-Teil sicherstellen. Mit sogenanntem Noise bringen wir ein wenig Variabilität in’s Spiel. Hierzu nutzen wir die random.uniform() Funktion. Diese wirft zwischen den genannten Grenzen eine Zufallszahl aus. Hierbei handelt es sich um eine Gleichverteilung.

    Im Anschluss starten wir eine weitere Schleife. Diesmal über eine range(1, 5 + 1). 5 ist hierbei die Zahl der “Pyramiden” (oder “Nested Squares”, die wir haben wollen. Wir setzen unsere aktuellen Koordinaten auf die im vorigen Code-Schnipsel gespeicherten und bewegen uns von da aus relativ (.rel_move_to) um eine zufällige Zahl random_noise nach rechts unten. Die Zahl wird mit jeder Iteration weiter mit der Iterations-Variable i multipliziert. So geht unser Zeichenstift also jede Iteration weiter vom Ursprungspunkt weg.

    An dieser Stelle legen wir nun noch die Maße des nächsten Rechtecks fest. Dieses soll natürlich kleiner sein als das jeweils vorige, weshalb auch hier in der Formel die Iterations-Variable auftaucht (512/8) - (i * 10). Wie groß ist das Rechteck also? Ein Achtel der Gesamtbreite ist unser Schachfeld. (i * 10) kleiner ist das Rechteck. (Die 10 ist übrigens “die gleiche”, wie die 10 bei unserem random_noise Generator – aber dazu gleich mehr).

    Jetzt wird das Rechteck erstellt und dann gezeichnet. Et voila!

    random_noise = random.uniform(1, 10)
    for i in range(1, 5 + 1):
        context.move_to(*current_point)
        context.rel_move_to(i * random_noise, i * random_noise)
        rect_w, rect_h = (512/8) - (i * 10), (512/8) - (i * 10)
        context.rectangle(*context.get_current_point(), rect_w, rect_h)
        context.stroke()

    Na schau mal einer an! Schon sind wir fertig. In meinem Experiment lief das bei Weitem nicht so einfach. Da musste ich viel hin und her probieren, wie das Verhältnis zwischen Bildgröße, Gridgröße und den Variablen, wie Noise und Pyramiden-Zahl sein muss. Dabei bin ich auf eine sehr schöne Auflösung gestoßen. Das wichtigste an dem ganzen Spaß scheint die noise Variable zu sein. Diese hat nun ein Verhältnis zu allen anderen Variablen. Ich erstze also alle hart-kodierten Zahlen durch die Variablen und erhalte folgenden Gesamt-Code

    import cairo
    import random
    
    size = 512
    grid = 8
    pyramids = 5
    noise = size/grid/pyramids
    
    with cairo.SVGSurface("nested_squares.svg", size, size) as surface:
        context = cairo.Context(surface)
    
        context.set_source_rgb(0, 0, 0)
        context.rectangle(0, 0, size, size)
        context.fill()
        
        context.set_source_rgb(1, 0.1, 0.4)
        context.rectangle(0, 0, size, size)
        context.stroke()
        
        for x in range(grid):
            for y in range(grid):
                context.move_to(x * size/grid, y * size/grid)
                current_point = context.get_current_point()
                rect_w, rect_h = size/grid, size/grid
                context.rectangle(*context.get_current_point(), rect_w, rect_h)
                context.stroke()
                
                random_noise = random.uniform(1, noise)
                for i in range(1, pyramids + 1):
                    context.move_to(*current_point)
                    context.rel_move_to(i * random_noise, i * random_noise)
                    rect_w, rect_h = (size/grid) - (i * noise), (size/grid) - (i * noise)
                    context.rectangle(*context.get_current_point(), rect_w, rect_h)
                    context.stroke()
        
        surface.write_to_png("nested_squares.png")

    Die letzte Zeile speichert die Datei zur einfachen Verwertung noch zusätzlich als PNG ab. Spannender ist aber mit Sicherheit die noise Variable. Damit ich auf unterschiedlichen Canvas-Größen arbeiten kann, habe ich sie als Verhältnis von Gesamtgröße zu Grid zu gewünschten Pyramiden erstellt. Scheint auf den meisten Größen zu funktionieren. Mit size, grid & pyramids kann man dann nach belieben rumspielen. Hier einmal der Unterschied zwischen grid = 12, pyramids = 6, grid = 4, pyramids = 10 und unseren bisherigen Werten grid = 8, pyramids = 5.

    Wie man sieht, funktioniert die Noise-Verteilung recht gut auch auf unterschiedlichen Variablen. Damit ist mein Ziel erreicht!

    Das Titelbild des Posts ist übrigens mit size = 2048, grid = 40, pyramids = 5 entstanden. Einfach nur fantastisch! Zum Abschluss noch ein Ausblick, wo das hinführen kann. Leichter Bruch in der Farbwahl, aber was kann ich dafür, was der Randomisierer an Farben ausspuckt 😉 Hier Pastell Hintergrund mit weniger Pastell Pyramiden. Passt extrem gut mit den Mustern, würde ich sagen!

    Das ist nur der Anfang meiner Reise in die unendlichen Tiefen der Generative Art. Komm mit mir und tausch Dich gern mit mir aus.


  • Generative Art mit Python Pillow

    Heute hat der Hyperfokus mich gepackt und ich hab meine allerersten Versuche mit generativer Kunst gemacht. Was soll das sein? Bei generative art geht es darum, mit Hilfe von Code Dinge zu krerieren. Das können Melodien, Videos oder ganz klassisch 2D Grafiken sein. Für den Anfang ist letzteres meine Wahl gewesen.

    Den Einstieg mache ich mit Python pillow. Einer Library zur Bildmanipulation.

    Den gesamten Code gibt es zur Zeit noch nicht öffentlich zugänglich. Dafür bin ich noch zu sehr im Experimentieren und der Code entsprechend unaufgeräumt. Teile des Codes erkläre ich aber hier.

    Was kreiere ich?

    Spannend ist was anderes, oder? Nun ja, ich fange ja gerade erst an. Von daher gibt’s ersteinmal die Grundlagen.

    Ich habe mir erst überlegt, wie man mit sehr wenigen Elementen zumindest ein stimmiges Bild erschaffen kann. Also habe ich die benötigten Python-Module installiert und losgelegt.

    import random
    from PIL import Image, ImageDraw, ImageColor, ImageFont

    Das pillow Modul ist für das Erschaffen der Grafik verantwortlich. Wir erstellen ein canvas, geben ihm eine zufällige Hintergrundfarbe (Format RGB, mit R, G, B Werten von 0 bis 255) und fangen an zu malen.

    canvas = Image.new('RGB', (256, 256))
    
    random_color = ((random.randint(0, 255), 
                     random.randint(0, 255), 
                     random.randint(0, 255)))
    
    canvas.paste(random_color, [0, 0, 256, 256])

    Jetzt haben wir sogar schon den ersten generativen Part eingebaut. Die Hintergrundfarbe wird per Zufallsgenerator festgelegt. Weil ich die Farben etwas “Pastell”-artig haben möchte, mische ich sie mit reinem Weiß (RGB(255, 255, 255)).

    color_pastel = tuple([round((x + 255) / 2) for x in random_color])

    Als nächstes möchte ich das Bild mit Elementen füllen. Diese sollen eine andere Farbe haben als der Hintergrund. Damit sie erkennbar sind, stelle ich sicher, dass ein bestimmter Kontrast vorherrscht. Dazu bediene ich mich an den gängigen Luminanz-Formeln für Kontraste im Kontext der Barrierefreiheit (Accessibility).

    def color_contrast(color1, color2):
        
        def color_luminance(color):
            rsrgb = color[0] / 255.0
            gsrgb = color[1] / 255.0
            bsrgb = color[2] / 255.0
            r_l = 0.2126 * ((rsrgb + 0.055) / 1.055) ** 2.4 if rsrgb > 0.04045 else rsrgb / 12.92
            g_l = 0.7152 * ((gsrgb + 0.055) / 1.055) ** 2.4 if gsrgb > 0.04045 else gsrgb / 12.92
            b_l = 0.0722 * ((bsrgb + 0.055) / 1.055) ** 2.4 if bsrgb > 0.04045 else bsrgb / 12.92
            return r_l + g_l + b_l
        
        l1, l2 = color_luminance(color1), color_luminance(color2)
        
        return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)

    Für meine Elemente hole ich mir also eine weitere random_color und schaue dann, ob der Kontrast hoch genug ist. Laut WCAG2.0 Richtlinie benötigt man einen solchen Kontrast für dekorative Elemente zwar nicht, aber allein aus ästhetischen Gründen, sortiere ich hier nach Kontrast > 2. Ist die generierte Farbe nicht kontrastreich genug zum Hintergrund, wird eine neue Farbe generiert und das Spiel wiederholt.

    Wenn ich eine passende Farbe gefunden habe, was gerne mal einige tausend Iterationen lang dauern kann, beginne ich mit meinen Elementen. Hier habe ich mich für ein simples Quadrat als Frame und ein Dreieck als Hauptakzent entschieden. Zuerst wird der Frame gemalt.

    draw_image.rectangle((padding, padding, size - padding, size - padding), fill=None, outline=accent_color, width=1)

    padding gibt hier an, wie viel Platz der Frame nach außen zum Bildrand haben soll. In meinem Code ist dieser prozentual an der Gesamtgröße festgelegt. Das Rechteck bekommt keine Fill-Color sondern nur die vorhin randomisiert erstellte accent_color als Outline.

    Und schon geht es weiter zum Dreieck. Ein Dreieck zu erstellen, ist ersteinmal gar nicht schwierig. Wir brauchen drei Koordinatenpunkte und sind fertig. Diese sollen natürlich in einem gewissen Rahmen auf dem Bild platziert werden, damit sie nicht zu nah am Bildrand sind. Wir generieren also die Koordinaten mit entsprechenden Parametern. Zustäzlich gehen wir noch sicher, dass die Dreiecks-Fläche groß genug ist, damit sie erkennbar ist. Somit verhindern wir sehr spitze Dreiecke, die eventuell sogar nur ausssehen, wie Linien. Mit all diesen Anforderungen, wird unser Dreiecks-Generator schnell komplex.

    def random_triangle(seed=None, canvas_size=IMAGE_SIZE, canvas_padding=PADDING, min_area=None):
        
        def random_position(seed=seed, canvas_size=canvas_size, canvas_padding=canvas_padding):
            if seed: random.seed(seed)
            return random.randint(canvas_padding, canvas_size - canvas_padding)
        
        if not min_area: min_area = canvas_size * 25
        position_iterations = 0
        min_area_reduced = False
        while True:
            x1, y1 = random_position(), random_position()
            x2, y2 = random_position(), random_position()
            x3, y3 = random_position(), random_position()
    
            area = abs((x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1)) / 2
            
            position_iterations += 1
            
            if position_iterations >= MAX_ITERATIONS_TRIANGLE:
                min_area_reduced = MIN_AREA
                      
                if area >= min_area_reduced:
                    break  
    
            if area >= min_area:
                break
        
        triangle = ((x1, y1), (x2, y2), (x3, y3))
        
        return triangle, position_iterations

    Mit der selben Farbe wie der Frame, wird nun auch das Dreieck gemalt. triangle ist hier mit der obigen Funktion generiert.

    draw_image.polygon(triangle, fill=accent_color, outline=None)

    Und schon sind wir fertig. Die Datei kann nun gespeichert werden.

    canvas.save(f'./{OUTPUT_FOLDER}/{seed}.{output_format}')

    seed wird später noch spannend. Hier ersteinmal, was wir gespeichert haben:

    Okay, wie oben gesagt. Doch gar nicht so spannend. Und das kann ich in Figma in wenigen Sekunden selbst erstellen und hab dazu auch noch Kontrolle über Farben und Element-Größen. Warum also Generative Art? Nun, ich kann meine Funktion in einem Loop laufen lassen und mehrere Grafiken auf einmal generieren.

    for amount in range(25):
        create_canvas()[0].save(f"./tmp/{amount}.jpg")

    Und das mache ich in Bruchteilen von Sekunden. Da kann Figma natürlich nicht mehr mithalen.

    Und das ist schon die Kunst hinter Generative Art. Ich bin echt begeistert, wie schnell so etwas geht und unglaublich gespannt, was ich noch so hin bekommen kann.

    Was es mit seed auf sich hat, und wie ich beim Bild vom Anfang gelandet bin, kommt dann in Bälde als Teil 2 in diesen Blog. Und ein Behind The Scenes (BTS) Beitrag mit technischen Details zu meinem Python JupyterLab Server gibt’s bestimmt auch in Zukunft noch.

    Wenn Du Dich mit mir über Code im Allgemeinen oder Generative Art im Speziellen austauschen möchtest, schreib mir gerne. Zum Beispiel per Mail.


  • Hut ab! = no|hut


  • Other Side


  • Daten aus Datenbank visualisieren – NocoDB + Metabase

    Ich tracke schon seit einer Weile einige Gesundheitsdaten mit einer selbstgehosteten NocoDB Datenbank. Was bisher gefehlt hat, war die Visualisierung der Daten. Da kommt nun Metabase in’s Spiel. Dazu habe ich Metabase einfach in meinen NocoDB Stack integriert:

    version: "2.1"
    
    services: 
      nocodb: 
        image: nocodb/nocodb:${TAG:-latest}
        container_name: ${SERVICE:-nocodb}-app
        depends_on: 
          root_db: 
            condition: service_healthy
        env_file: .env
        ports: 
          - ${IP:-10.0.0.3}:${PORT:-4444}:8080
        restart: always
        volumes: 
          - ${DOCKER_VOLUME_STORAGE:-/mnt/docker-volumes}/${SERVICE:-nocodb}/data:/usr/app/data
    
      root_db: 
        image: mysql:${TAG_DB:-8.0.32}
        container_name: ${SERVICE:-nocodb}-db
        env_file: .env
        ports:
          - ${IP:-10.0.0.3}:${PORT_DB:-3336}:3306
        healthcheck: 
          retries: 10
          test: 
            - CMD
            - mysqladmin
            - ping
            - "-h"
            - localhost
          timeout: 20s
        restart: always
        volumes: 
          - ${DOCKER_VOLUME_STORAGE:-/mnt/docker-volumes}/${SERVICE:-nocodb}/database:/var/lib/mysql
    
      metabase:
        image: metabase/metabase:${TAG_METABASE:-latest}
        container_name: ${SERVICE:-nocodb}-metabase
        volumes:
          - ${DOCKER_VOLUME_STORAGE:-/mnt/docker-volumes}/${SERVICE:-nocodb}/metabase:/metabase
        ports:
          - ${IP:-10.0.0.3}:${PORT_METABASE:-3333}:3000
        depends_on:
          root_db:
            condition: service_healthy
        env_file: .env
    
    volumes: 
      data: {}
      database: {}
      metabase: {}

    Die Services in’s selbe Netzwerk packen:

    networks:
      default:
        external: true
        name: default

    Das .env File sieht dann so aus:

    TZ=Europe/Berlin
    
    MYSQL_DATABASE=root_db
    MYSQL_PASSWORD=password
    MYSQL_RANDOM_ROOT_PASSWORD=true
    MYSQL_USER=noco
    
    MB_DB_TYPE=mysql
    MB_DB_DBNAME=${MYSQL_DATABASE:-root_db}
    MB_DB_PORT=3306
    MB_DB_USER=${MYSQL_USER:-noco}
    MB_DB_PASS=${MYSQL_PASSWORD:-password}
    MB_DB_HOST=${SERVICE:-nocodb}-db
    
    NC_DB="mysql2://${MYSQL_DATABASE:-root_db}:${MB_DB_PORT:-3306}?u=${MYSQL_USER:-noco}&p=${MYSQL_PASSWORD:-password}&d=${MYSQL_DATABASE:-root_db}"

    Dort muss man nur das MYSQL_PASSWORD anpassen.

    In Metabase dann die Verknüpfung zur Datenbank herstellen.

    Und schon kann das freudige Visualisieren losgehen.

    So lassen sich auf die Schnelle wunderschöne Dashboards zusammenklicken.


  • About last night


  • Winter in Bayern


  • Moody Forest