Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
718 changes: 718 additions & 0 deletions .gitignore

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions AnimationCSVFormat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#Animation File Format

The animation files are CSV (comma separated value) spreadsheet files.

The first row contains the column names which are used to identify the contents of the column.
Every subsequent row contains the data for each frame.

The header names are:

`FRAME_ID` - This stores the index of the frame.
This column should contain integers.
Lowest values will be displayed first.
This column is optional. If undefined the frames will be displayed in the order they are in the CSV file.

`FRAME_TIME` - The amount of time the frame will remain for in milliseconds.
This should contain ints or floats eg a value of 33.33 is 33.33ms or 1/30th of a second.
This column is optional. If undefined will default to 0 and will run as fast as the hardware will allow.

`[RGB]_[0-9]+` - The intensity of each colour channel for the given LED index.
Examples are `R_0`, `G_0` and `B_0` which are the red, green and blue channel for LED 0.
The values of these columns should be floats or ints between 0 and 255 inclusive.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ This repository contains the code and coordinates used for Matt's 2021 Christmas

Code in the `examples` folder has been provided by other contributors!

The format of the CSV animation files can be found in [AnimationCSVFormat.md](AnimationCSVFormat.md)

The code to run the animation CSV files can be found in [execution](execution)

Most of what you need is probably over on the Harvard Graduate School of Design repository: ["GSD-6338: Introduction to Computational Design"](https://github.com/GSD6338)

## Usage
Expand Down
18 changes: 18 additions & 0 deletions execution/flush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Based on code from https://github.com/standupmaths/xmastree2020

import board
import neopixel


def main():
number_of_leds = 500
pixels = neopixel.NeoPixel(board.D18, number_of_leds)

for led in range(number_of_leds):
pixels[led] = (0, 0, 0)

print("Done")


if __name__ == "__main__":
main()
110 changes: 110 additions & 0 deletions execution/run-folder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Based on code from https://github.com/standupmaths/xmastree2020
# Modified heavily by gentlegiantJGC

from typing import List
import os
import argparse

import board
import neopixel

from run_utils import parse_animation_csv, draw_frames, draw_lerp_frames, Sequence

NUMBER_OF_LEDS = 500


def run_folder(folder_path: str, loops_per_sequence: int, transition_frames: int):
print(f"Sequences will loop {loops_per_sequence} times")
print(f"Sequences will blend over {transition_frames} frames")

print("Loading animation spreadsheets. This may take a while.")

# Load and parse all the sequences at the beginning (it's a heavy process for the pi)
csv_files: List[str] = []
sequences: List[Sequence] = []
for file_name in os.listdir(folder_path):
full_path = os.path.join(folder_path, file_name)
if file_name.endswith(".csv") and os.path.isfile(full_path):
try:
# try loading the spreadsheet and report any errors
sequence = parse_animation_csv(full_path, NUMBER_OF_LEDS, "RGB")
except Exception as e:
print(f"Failed loading spreadsheet {file_name}.\n{e}")
else:
# if the spreadsheet successfully loaded then add it to the data
sequences.append(sequence)
csv_files.append(file_name)

print("Finished loading animation spreadsheets.")

# Init the neopixel
pixels = neopixel.NeoPixel(
board.D18, NUMBER_OF_LEDS, auto_write=False, pixel_order=neopixel.RGB
)

last_frame = None

# Play all sequences in a loop
while True:
# iterate over the sequences
for sequence_id, (file_name, (frames, frame_times)) in enumerate(
zip(csv_files, sequences)
):
print(f"Playing file {file_name}")
for loop in range(0, loops_per_sequence):
# run this bit as many as was requested
if (
last_frame is not None
and frames
and any(
# if any of the colour channels are greater than 20 points different then lerp between them.
abs(channel_b - channel_a) > 20
for led_a, led_b in zip(last_frame, frames[0])
for channel_a, channel_b in zip(led_a, led_b)
)
):
# if an animation has played and the last and first frames are different enough
# then interpolate from the last state to the first state
# Some animations may be designed to loop so adding a fade will look weird
draw_lerp_frames(pixels, last_frame, frames[0], transition_frames)

print(f"Loop {loop + 1} of {loops_per_sequence}")

# push all the frames to the tree
draw_frames(pixels, frames, frame_times)

# Store the last frame if it exists
if frames:
last_frame = frames[-1]


def main():
# parser to parse the command line inputs
parser = argparse.ArgumentParser(description="Run all spreadsheet in a directory.")
parser.add_argument(
"csv_directory",
metavar="csv-directory",
type=str,
help="The absolute or relative path to a directory containing csv files.",
)
parser.add_argument(
"loops_per_sequence",
type=int,
nargs="?",
default=5,
help="The number of times each sequence loops. Default is 5.",
)
parser.add_argument(
"transition_frames",
type=int,
nargs="?",
default=15,
help="The number of frames (at 30fps) over which to transition between sequences. "
"Set to 0 to disable interpolation.",
)
args, _ = parser.parse_known_args()
run_folder(args.csv_directory, args.loops_per_sequence, args.transition_frames)


if __name__ == "__main__":
main()
44 changes: 44 additions & 0 deletions execution/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Based on code from https://github.com/standupmaths/xmastree2020
# Modified heavily by gentlegiantJGC

import argparse

import board
import neopixel

from run_utils import parse_animation_csv, draw_frames

# change if your setup has a different number of LEDs
NUMBER_OF_LEDS = 500


def load_and_run_csv(csv_path):
frames, frame_times = parse_animation_csv(csv_path, NUMBER_OF_LEDS, "RGB")
print("Finished Parsing")

pixels = neopixel.NeoPixel(
board.D18, NUMBER_OF_LEDS, auto_write=False, pixel_order=neopixel.RGB
)

# run the code on the tree
while True:
draw_frames(pixels, frames, frame_times)


def main():
# parser to parse the command line inputs
parser = argparse.ArgumentParser(description="Run a single spreadsheet on loop.")
parser.add_argument(
"csv_path",
metavar="csv-path",
type=str,
help="The absolute or relative path to the csv file.",
)

args, _ = parser.parse_known_args()
csv_path = args.csv_path
load_and_run_csv(csv_path)


if __name__ == "__main__":
main()
150 changes: 150 additions & 0 deletions execution/run_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Written by gentlegiantJGC

from typing import Tuple, List, Optional, NamedTuple
import csv
import time

import neopixel

Color = Tuple[float, float, float]
Frame = List[Color]
Frames = List[Frame]
FrameTime = float
FrameTimes = List[FrameTime]
Sequence = NamedTuple("Sequence", [("frames", Frames), ("frame_times", FrameTimes)])


def parse_animation_csv(
csv_path: str, number_of_leds: int, channel_order="RGB"
) -> Sequence:
"""
Parse a CSV animation file into python objects.

:param csv_path: The path to the csv animation file
:param number_of_leds: The number of LEDs that the device supports
:param channel_order: The order the channels should be loaded. Must be "RGB" or "GRB"
:return: A Sequence namedtuple containing frame data and frame times
"""
if channel_order not in ("RGB", "GRB"):
raise ValueError(f"Unsupported channel order {channel_order}")
# parse the CSV file
# The example files in this repository start with \xEF\xBB\xBF See UTF-8 BOM
# If read normally these become part of the first header name
# utf-8-sig reads this correctly and also handles the case when they don't exist
with open(csv_path, "r", encoding="utf-8-sig") as csv_file:
# pass the file object to reader() to get the reader object
csv_reader = csv.reader(csv_file)

# this is a list of strings containing the column names
header = next(csv_reader)

# read in the remaining data
data = list(csv_reader)

# create a dictionary mapping the header name to the index of the header
header_indexes = dict(zip(header, range(len(header))))

# find the column numbers of each required header
# we should not assume that the columns are in a known order. Isn't that the point of column names?
# If a column does not exist it is set to None which is handled at the bottom and populates the column with 0.0
led_columns: List[Tuple[Optional[int], Optional[int], Optional[int]]] = [
tuple(
header_indexes.pop(f"{channel}_{led_index}", None)
for channel in channel_order
)
for led_index in range(number_of_leds)
]

if "FRAME_ID" in header_indexes:
# get the frame id column index
frame_id_column = header_indexes.pop("FRAME_ID")
# don't assume that the frames are in chronological order. Isn't that the point of storing the frame index?
# sort the frames by the frame index
data = sorted(data, key=lambda frame_data: int(frame_data[frame_id_column]))
# There may be a case where a frame is missed eg 1, 2, 4, 5, ...
# Should we duplicate frame 2 in this case?
# For now it can go straight from frame 2 to 4

if "FRAME_TIME" in header_indexes:
# Add the ability for the CSV file to specify how long the frame should remain for
# This will allow users to customise the frame rate and even have variable frame rates
# Note that frame rate is hardware limited because the method that pushes changes to the tree takes a while.
frame_time_column = header_indexes.pop("FRAME_TIME")
frame_times = [float(frame_data[frame_time_column])/1000 for frame_data in data]
else:
# if the frame time column is not defined then run as fast as possible like the old code.
frame_times = [0] * len(data)

frames = [
[
tuple(
# Get the LED value or populate with 0.0 if the column did not exist
0.0 if channel is None else float(frame_data[channel])
# for each channel in the LED
for channel in channels
)
# for each LED in the chain
for channels in led_columns
]
# for each frame in the data
for frame_data in data
]
return Sequence(frames, frame_times)


def draw_frame(pixels: neopixel.NeoPixel, frame: Frame, frame_time: float):
"""
Draw a single frame and wait to make up the frame time if required.

:param pixels: The neopixel interface
:param frame: The frame to draw
:param frame_time: The time this frame should remain on the device
"""
t = time.perf_counter()
for led in range(pixels.n):
pixels[led] = frame[led]
pixels.show()
end_time = t + frame_time
while time.perf_counter() < end_time:
time.sleep(0)


def draw_frames(pixels: neopixel.NeoPixel, frames: Frames, frame_times: FrameTimes):
"""
Draw a series of frames to the tree.

:param pixels: The neopixel interface
:param frames: The frames to draw
:param frame_times: The frame time for each frame
"""
for frame, frame_time in zip(frames, frame_times):
draw_frame(pixels, frame, frame_time)


def draw_lerp_frames(
pixels: neopixel.NeoPixel,
last_frame: Frame,
next_frame: Frame,
transition_frames: int,
):
"""
Interpolate between two frames and draw the result.

:param pixels: The neopixel interface
:param last_frame: The start frame
:param next_frame: The end frame
:param transition_frames: The number of frames to take to fade
"""
for frame_index in range(1, transition_frames):
ratio = frame_index / transition_frames
draw_frame(
pixels,
[
tuple(
round((1 - ratio) * channel_a + ratio * channel_b)
for channel_a, channel_b in zip(led_a, led_b)
)
for led, (led_a, led_b) in enumerate(zip(last_frame, next_frame))
],
1 / 30,
)
23 changes: 23 additions & 0 deletions execution/turnon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Based on code from https://github.com/standupmaths/xmastree2020

import time
import sys

import board
import neopixel


def main():
number_of_leds = 500
pixels = neopixel.NeoPixel(board.D18, number_of_leds)

ids = sys.argv[1:]
for led in ids:
pixels[int(led)] = (255, 255, 255)
time.sleep(1)

print("Done")


if __name__ == "__main__":
main()