Glyph Generator

Thanks Pix, I’ll give it a whirl on the weekend.

2 Likes

Hey Pix, I’m a bit confused about the ‘location’ parameter - what should it be and how do I use it?

That’s fair.

A location is a Vec2 object define towards the beginning of the script, which is an x and a y co-ordinate.

It’s really a visible scar left behind by the port over from rust - a language that thrives off structs and struct composition. If I could be bothered to re-write this, the pythonic way of packaging this data this would be to use a tuple (possibly a named tuple if I had a few glasses of nice wine and felt a wee bit fancy pancy).

why is Vec2.x set to half the size of the canvas?

The location vector represents to top left pixel of the algorithm.
HOWEVER!!! Recall that the algorithm only draws HALF of a monster.

#Paint it symetrically
monster.set(pxl, py, colour) #draw from sinistral to medial {---->. |       }
monster.set(pxr, py, colour) #draw from dextral to medial   {       |.<---- }

The magic behind the math here is that your monkey brain sees patterns in symmetry.
So in our case the Location Vector is the * because that’s the “top left” of one side of the monster.

{       *       }
{               }
{               } 

Why do you need a location?

The punch line is a monster does not always fill a canvas.
The first parameter you get is a size which, although functionally means the scale of the pixamon, really refers to the height & width in pixels of your final image.

assert Pixamon._size == len(Pixamon.data[0]) #x
assert Pixamon._size == len(Pixamon.data[0][0]) #y

Because that monster doesn’t fill that canvas, you may want to translate it within the canvas to get a nicer shot. The algorithm has RNG so you can get situations where to monster is high or low within the canvas, i.e. not centered. Perhaps a monster up high looks like it’s flying; a monster down low may be crawling. Vec2.y allows you to make those creative choices should you be bothered.

Modifying Vec2.x can also lead to some weird affects because of all the fmap() but that’s up to you.

TL;DR

You don’t need to touch it, it’s set to a sensible default. You are welcome to muck around with it should you desire.

Hope that clears some things up.
Pix :heavy_heart_exclamation:

3 Likes

Hey Pix,

It took me a while since my coding skills are still pretty basic, but with a bit of AI assistance, I’ve got a working solution! Some of the monsters are looking really good.

I’ll grab my SLR and record about half an hour of transitions. As long as chores don’t get in the way, I should have it uploaded today.

Here’s version 101 of the code…

# monster101.py - MicroPython on RP2040 MCU with 4x LED 8x8 matrix
#
# This software is a derivative work of original code by Jonny J Watson, licensed under the MIT License.
# The derivative portions of this software, created by Mark Makies, licensed under CC BY-SA 4.0.
#
# Copyright (c) 2023 Jonny J Watson
# MIT License (https://opensource.org/licenses/MIT)
# Original source: https://github.com/pixmusix/gen_me_a_pixamon
#
# Copyright (c) 2025 Mark Makies
# CC BY-SA 4.0 License (http://creativecommons.org/licenses/by-sa/4.0/)

# pyright: reportMissingImports=false

from machine import Pin, unique_id
import ubinascii
from random import random, seed, choice, uniform, randint
from math import floor
from utime import sleep_ms
import neopixel

# --- Initialization ---
print('Random Pixel Monster Generator v1.01')
board_id = unique_id()
print(f'On RP2040 Waveshare RP2040-Zero Pico-like, ID: {ubinascii.hexlify(board_id).upper().decode()}')

# --- Constants ---
NUM_PIXELS = 256  # Total number of pixels in the NeoPixel matrix
GRID_SIZE = 16
FADE_STEP_SIZE = 2  # Controls speed of transition
MAX_RGB_VALUE = 32  # Limit brightness for NeoPixels
MIN_DELAY = 1000  # in ms
MAX_DELAY = 4000

# --- NeoPixel Setup ---
NEO_PIN = Pin(14, Pin.OUT)
NEO = neopixel.NeoPixel(NEO_PIN, NUM_PIXELS)

# --- Classes ---
class ArgumentException(Exception):
    pass

class Pixamon:
    def __init__(self, size):
        self._size = size
        self.data = self.get_blank_data()

    def get_blank_data(self):
        return [[[0, 0, 0] for _ in range(self._size)] for _ in range(self._size)]

    def set(self, x, y, rgb):
        if not self.valid_colour(rgb):
            raise ArgumentException('RGB data must be a tuple of 3 uint8 values')
        if not self.valid_coords(x, y):
            raise ArgumentException('Invalid Coordinates')
        self.data[x][y] = list(rgb)

    def get_rgb(self, x, y):
        if not self.valid_coords(x, y):
            raise ArgumentException('Invalid Coordinates')
        return self.data[x][y]

    def is_colour_at(self, x, y):
        return any(self.data[x][y])

    def get_rotated90(self):
        rotated_data = self.get_blank_data()
        for i in range(self._size):
            for j in range(self._size):
                rotated_data[j][self._size - 1 - i] = self.data[i][j]
        return rotated_data

    def valid_coords(self, x, y):
        return 0 <= x < self._size and 0 <= y < self._size  # Fixed condition

    def valid_colour(self, rgb):
        return len(rgb) == 3 and all(isinstance(c, int) and 0 <= c < 256 for c in rgb)

    def draw_to_console(self):
        rota = self.get_rotated90()
        for row in rota:
            print(''.join('*' if any(pixel) else ' ' for pixel in row))

class Vec2:
    def __init__(self, x, y):
        self.x = x
        self.y = y

# --- Helper Functions ---
def make_random_colour():
    return [floor(random() * 255) for _ in range(3)]

def fmap(a_rge, b_rge, c):
    return b_rge.x + (c - a_rge.x) * (b_rge.y - b_rge.x) / (a_rge.y - a_rge.x)

def make_monster(mon_seed, size, loc):
    seed(mon_seed)
    monster = Pixamon(size)
    flip_axis = choice([True, False])

    w = size - 3 if flip_axis else size // 2 - 1
    h = size - 3 if not flip_axis else size // 2 - 1

    normal = Vec2(0.0, 1.0)
    density = fmap(normal, Vec2(0.8, 0.9), random())
    y_bias = fmap(normal, Vec2(0.2, -0.2), random())

    for k in range(int(w * h)):
        i, j = (k // w, k % w) if flip_axis else (k % w, k // w)

        is_hole = random() > density
        a_scalar = floor(uniform(0, size / 2)) * floor(uniform(0, size / 2))
        y_scalar = floor(1.0 - 2.0 * y_bias)
        xy_scalar = pow(i, 2) + pow(j - y_scalar * (h / 2), 2)
        bias = a_scalar > xy_scalar

        if bias and not is_hole:
            pxl, pxr, py = floor(loc.x - i) % size, floor(loc.x + i) % size, floor(loc.y + j) % size
            colour = make_random_colour()
            monster.set(pxl, py, colour)
            monster.set(pxr, py, colour)

    return monster

# --- NeoPixel Functions ---
def convert_to_neo_format(monster, ROTATION=90):
    size = len(monster.data)
    neo_pixel_data = []

    def rotate_coordinates(x, y, rotation):
        if rotation == 90:
            return y, size - 1 - x
        elif rotation == 180:
            return size - 1 - x, size - 1 - y
        elif rotation == 270:
            return size - 1 - y, x
        return x, y

    def translate_pixel_index(index):
        matrix_size = 8
        panel_width = 16
        panel_row, panel_col = divmod(index, panel_width)

        if panel_row < matrix_size:
            if panel_col < matrix_size:
                return panel_row * matrix_size + panel_col
            return panel_row * matrix_size + (panel_col - matrix_size) + 64
        if panel_col < matrix_size:
            return (panel_row - matrix_size) * matrix_size + panel_col + 192
        return (panel_row - matrix_size) * matrix_size + (panel_col - matrix_size) + 128

    for row in range(size):
        for col in range(size):
            rotated_x, rotated_y = rotate_coordinates(col, row, ROTATION)
            pixel = monster.data[rotated_y][rotated_x]
            translated_index = translate_pixel_index(row * size + col)
            neo_pixel_data.append((translated_index, tuple(pixel)))

    neo_pixel_data.sort(key=lambda x: x[0])
    return [pixel for _, pixel in neo_pixel_data]

def scale_rgb(color):
    max_channel = max(color)
    return tuple(int(c * min(1.0, MAX_RGB_VALUE / max_channel)) for c in color) if max_channel > 0 else color

def update_neo_pixel_strip(NEO, new_pixels):
    """Smoothly transitions NeoPixels to a new pattern with brightness scaling."""
    current_colors = [NEO[i] for i in range(len(NEO))]
    target_colors = [scale_rgb(color) for color in new_pixels]

    while True:
        completed_pixels = 0  # Reset counter each iteration

        for i in range(len(NEO)):
            new_color = []
            for current, target in zip(current_colors[i], target_colors[i]):
                if current < target:
                    next_value = min(current + FADE_STEP_SIZE, target)
                elif current > target:
                    next_value = max(current - FADE_STEP_SIZE, target)
                else:
                    next_value = target  # No change needed
                    completed_pixels += 1  # Count when all channels match

                new_color.append(next_value)

            current_colors[i] = tuple(new_color)

        # If all pixels have reached their target color, exit loop
        if completed_pixels >= len(NEO) * 3:  # *3 accounts for (R, G, B)
            break

        # Apply the updated colors to NeoPixel
        for i, color in enumerate(current_colors):
            NEO[i] = color
        NEO.write()

def run_random_pixamon_cycle(NEO):
    while True:
        my_pixamon = make_monster(randint(0, 10000), GRID_SIZE, Vec2(GRID_SIZE // 2, 3))
        update_neo_pixel_strip(NEO, convert_to_neo_format(my_pixamon))
        sleep_ms(randint(MIN_DELAY, MAX_DELAY))

if __name__ == '__main__':
    run_random_pixamon_cycle(NEO)

2 Likes

Keen :slight_smile:

1 Like

I’ve uploaded ten minutes of video to You Tube Monster 101a, but I haven’t had much luck removing the scan lines. I’m not sure what’s causing them this time - my Nikon captures at 50fps, non-interlaced, which should have avoided this issue. It might be an artifact of the LED matrix.

I tried the following FFmpeg command:

ffmpeg -i DSC_5833.MP4 -ss 5 -t 600 -vf "yadif=1,rotate=-0.2*(PI/180)" -c:v libx264 -an monster1.mp4

This helped slightly, but not enough - I’ll need to investigate further later.

I’m also dealing with severe colour fringing, which might be due to the lens choice this time. When I have time, I’ll test with a different lens, and if that doesn’t resolve it, I’ll try chromashift in FFmpeg. I’ve also cut off a bit of the bottom row accidently.

On the bright side, this makes for a good sample of content.

@Pixmusix what should I tweak to make them larger vertically?

2 Likes

Looks so cool. Might make my own one day. :partying_face:
I’d also love to see your glyph tool in real life one day so it’s a good excuse.

That’s a good question.
My instinct is to increase the y_bias variable.
I remember them to be quite sensitive.
Maybe have a play with the arguments going into fmap and see what comes out.

If you find the monsters blow out of disintegrate with tiny changes to the normal let me know and I’ll sit down one day and think about some kind of feed-forward technique to keep the monsters nice and boxy. Maybe we can feed the y_scalar from row n to row n+1. That kinda thing.

Let me know how you go.