Beating Heart Pattern

Made a “beating heart” pattern based on drawing a heart as the combination of a 45 degree tilted square and two semi-circles. Added simple anti-aliasing based on some tricks I’ve seen posted here. I’m happy with how it looks at higher resolution, though it’s definitely tricky to make defined shapes look good at lower resolution.

// Ratio of the height of the heart to the length of one side of the diamond inside it
// length of one side of the diamond is also the radius of the circle
var ratio = (0.5 + 3*sqrt(2)/4)
// Precompute
var sqr2_2 = sqrt(2)/2

export var delta = 0.06 // Max antialiasing distance
export var height = 0.8 // Height of heart in world units
export var xpos = 0.5   // x position of the point of the heart
export var ypos = (1-height)/2
export var L = height/ratio
export var Lv = L*sqr2_2


export function beforeRender(delta) {
  t1 = time(.02)
  t2 = time(.035)
  t3 = time(.057)
  height = 0.4 + 0.4*sin(t1*PI)
  L = height/ratio
  Lv = L*sqr2_2
  ypos = 0.4 + height/2 + 0.15*wave(t3)  // Position of the heart's "tip"
  xpos = 0.3 + 0.2*wave(t2) + 0.2*wave(t3)
  
}


// The position of the bottom heart point is (x0,y0)
// Heart height and x0, y0 are given in world units
export function drawHeart(x, y, x0, y0, height) {
  v = 0
  h = 0.5
  xn = abs(x - x0)    // Take advantage of symmetry - only compute half of heart
  yn = y0 - y         // Remove y offset of heart for computations
  if (yn < Lv) {      // Straight portion of heart
    if (xn < yn) {    // Inside straight portion
      v = 1
    } else {         // Check to see if we are close enough for anti aliasing
      d = (xn - yn)*sqr2_2   // Perpendicular distance to line x = y (makes a (90,45,45) triangle)
      if (d < delta) {       // Inside anti-aliasing distance
        h = 0
        v = 1-d/delta
      }
    }
  } else {                // Inside the curved portion of the heart
    xc = Lv/2             // (xc,yc) are coordinates of the center of the circular part of the heart
    yc = 3*Lv/2           
    yd = abs(yn - yc)     // Vertical distance from center of the circle
    if (yn < 2*Lv) {      // This portion of the heart is below the inverted point
      if (xn < Lv/2 + sqrt(L*L/4 - yd*yd)) {
        v = 1
      }
    } else {              // This portion of the heart is above the inverted point
      xd = abs(xn - xc)   // Horizontal distance to center of the circular part of the heart 
      if (xd < sqrt(L*L/4 - yd*yd)) {
        v = 1
      }      
    }
    // Anti alias the curved part of the heart
    if (v == 0) {
      d = hypot(xn - xc, yn - yc) - L/2
      if (d < delta) {
        v = 1-d/delta
        h = 0
      }
    }
  }
  hsv(y/2+t1,1,v*v)
}

export function render2D(index, x, y) {
  drawHeart(x,y,xpos, ypos,height)
}
6 Likes

Nice!

I did a heart pattern a while ago, but I cheated and extracted the data from a GIF:
Heartbeat 2D
Heartbeat 2D.epe (58.1 KB)

3 Likes

Beautiful!

I’ll be trying to adapt this to an 8x8.

2 Likes

Extracted from a gif? How did you do that? You have me thinking about how amazing it’d be to have Shrek dancing across my house without me having to do that much coding now!

1 Like

@GeekMomProjects Will you be uploading this to the pattern repository? It’s awesome!

1 Like

Thank you, I’m a bit hesitant to upload the pattern directly because the last time I uploaded a pattern, I realized afterwards that, while or worked well on my setup, it didn’t look that good on most other 2D LED layouts, and I’m not sure how versatile this pattern is yet, either. Also feel like I should document the math involved a bit better before putting it up with the other patterns. Please feel free to use the code from the post however you want.

Nice! You could get a bigger heart and skip the antialiasing on the straight edges by rotating the whole thing 45°… presuming it makes sense to also rotate the panel. :smiley:

1 Like

I used the following Python program:

save this as 'picToPat.py'
import argparse, os
from PIL import Image

# Parse command line.
parser = argparse.ArgumentParser()
parser.add_argument("pictureFile", help="The picture to be converted")
args = parser.parse_args()


#################
numImages = 1
#################


# Generate pattern code
print("//  Image helpers")
print("function numImages() { return _images.length; }")
print("function numFrames(image) { return _images[image][0]; }")
print("function numRows(image) { return _images[image][1]; }")
print("function numColumns(image) { return _images[image][2]; }")
print("//  Pixel helpers")
print("function pixelOffset(image, frame, row, column) { return (frame*numRows(image)*numColumns(image)) + (row*numColumns(image)) + column + 3; }")
print("function putPixel(image, frame, row, column, value) { _images[image][pixelOffset(image,frame,row,column)] = value; }")
print("function getPixel(image, frame, row, column, wrap) { if (column >= numColumns(image,frame)) { if (!wrap) return 0; else column = column % numColumns(image,frame); } return _images[image][pixelOffset(image,frame,row,column)]; }")
print("function RGBA(r, g, b, a) { return (r << 8) + g + (b >> 8) + (a >> 16); }")
print("function _r(pixval) { return ((pixval >> 8) & 0xff) >> 8; }")
print("function _g(pixval) { return ((pixval) & 0xff) >> 8; }")
print("function _b(pixval) { return ((pixval << 8) & 0xff) >> 8; }")
print("function _a(pixval) { return ((pixval << 16) & 0xff) >> 8; }")
print("//  Helpers to reduce the text size")
print("//  Viewport storage")
print("var viewportOffsetX = 0, viewportOffsetY = 0, viewportWrap = true;")
print("// Rendering functions")
print("export function render2D(index, x, y) {")
print("  dimension = sqrt(pixelCount); row = trunc(x * dimension); col = trunc(y * dimension);")
print("  pixval = getPixel(0, 0, row + viewportOffsetY, col + viewportOffsetX, viewportWrap);")
print("  rgb(pow(_r(pixval),2), pow(_g(pixval),2), pow(_b(pixval),2));")
print("}")
print("// Animation storage")
print("export var elapsedTime = 0, loopTime = 50;")
print("export function beforeRender(delta) {")
print("  elapsedTime += delta; if (elapsedTime > loopTime) {")
print("    elapsedTime = 0;")
print("    //  Scroll horizontally; wrap when we get to the end.")
print("    if (numColumns(0) > sqrt(pixelCount)) {")
print("      viewportOffsetX += 1; if (viewportOffsetX >= numColumns(0,0,0)) { viewportOffsetX = 0; }")
print("    }")
print("  }")
print("}")
print("")
print("")

# Allocate working storage
print("//  Image storage")
#print("var imageCount = %u;" % numImages)
print("var _images = array(%u); //  allocate image storage" % numImages)
#
# *****
# REPEAT THE FOLLOWING FOR EACH IMAGE FILE:
# *****
#
for iterImage in range(numImages):

    # read image and add its bitmaps to the pattern
    filename, extension = os.path.splitext(args.pictureFile)
    with Image.open(args.pictureFile, mode='r').convert('RGB') as im:
        pixels = im.load()
        print("// Image #%u (%ux%u) generated from '%s'" % (iterImage, im.width, im.height, args.pictureFile))

        # Process frames
        imageFrames = 1
        if getattr(im, "is_animated", False):
            imageFrames = im.n_frames
        print("  _images[%u] = array(3 + %u); _images[%u][0] = %u; _images[%u][1] = %u; _images[%u][2] = %u; // frames, rows, columns" % (iterImage, imageFrames*im.height*im.width, iterImage, imageFrames, iterImage, im.height, iterImage, im.width))

        # Dump image frame(s)
        for iterFrame in range(imageFrames):
            print("    // Frame #%u" % iterFrame)
            # Dump image pixels
            for iterRow in range(im.height):
                print("      // Row #%u: " % iterRow)
                for iterCol in range(im.width):
                    _r, _g, _b = im.getpixel((iterCol, iterRow))
                    _a = 0
                    numPixelsPerTextRow = 4
                    if iterCol % numPixelsPerTextRow == 0:
                        print('      ', end='')
                    print('putPixel(%u,%u,%u,%u,RGBA(0x%02x,0x%02x,0x%02x,0x%02x)); ' % (iterImage, iterFrame, iterRow, iterCol, _r, _g, _b, _a), end='')
                    if iterCol % numPixelsPerTextRow == (numPixelsPerTextRow - 1):
                        print('')

                # Finish this row.
                print("      // end Row #%u" % iterRow)
            # Finish this frame.
            print("    // end Frame #%u" % iterFrame)

        # Finish this image.
        print("  // end Image #%u" % iterImage)

    im.close()

…but:

  1. the image library I used doesn’t like some GIFs so those I’d have to explode into their individual frames using gifsicle and paste the generated arrays together manually; and

  2. a Pixelblaze can only hold around 10K array elements, so the combination of image size and frame count needs to be low enough to fit within that limit.

  3. For larger images I’ve needed to drop the packRGBA() helper function and load the arrays with larger chunks, store RGBA as a 31-bit literal by dropping one bit of resolution, or use a 16-color palette table where the image arrays contain 4-bit palette indexes, but I haven’t built any general-purpose tools for those steps.

1 Like

Thank you! I totally get the extra effort required before publishing. Most patterns have great comments and they’re very appreciated. Anyway, nice work!!

Please do! I can update them if needed. It’s a bit manual right now on my part, but preferable to forum only patterns as the patterns site has more visibility. Email me a new epe file.

1 Like

oh damn, that’s super cool :slight_smile:

1 Like

That is a hardware solution to a software problem!

2 Likes

Ok, cleaned up the code and uploaded it to patterns. Thanks!

2 Likes