From 5d275aff1ff1b0fa64e7d9e19887181f73953179 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Sun, 26 Dec 2021 12:33:34 +0000 Subject: [PATCH 01/13] Extended the gitignore --- .gitignore | 720 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 719 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 95f8709..962176f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,720 @@ /datset_py/ -*.rhl \ No newline at end of file +*.rhl + +# Created by https://www.toptal.com/developers/gitignore/api/python,windows,macos,linux,pycharm,visualstudio,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,windows,macos,linux,pycharm,visualstudio,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +!.vscode/*.code-snippets + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### VisualStudio ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Nuget personal access tokens and Credentials +# nuget.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools + +# Local History for Visual Studio Code + +# Windows Installer files from build outputs + +# JetBrains Rider +.idea/ +*.sln.iml + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/python,windows,macos,linux,pycharm,visualstudio,visualstudiocode From 46c706a6cb6a8e9a7c7ab1a5a53a0e4c70faa26e Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Sun, 26 Dec 2021 12:44:40 +0000 Subject: [PATCH 02/13] Rewritten the run.py to be more robust, cleaner and have some more features Cleaned up the code to make it more pythonic. Split the animation loading logic into its own function. Used the column headers to work how what each column was rather than assuming they are in a fixed order. Sort by the frame indexes rather than assuming the frames are in order. Force the code to run at 60fps unless configured. Added an optional column to store the frame time for each frame. --- 03_execution/run.py | 184 +++++++++++++++++++++++++++----------------- 1 file changed, 115 insertions(+), 69 deletions(-) diff --git a/03_execution/run.py b/03_execution/run.py index e0fad50..564e40d 100644 --- a/03_execution/run.py +++ b/03_execution/run.py @@ -1,73 +1,119 @@ # Based on code from https://github.com/standupmaths/xmastree2020 +from typing import List, Tuple, Optional +import time +import csv +import argparse + import board import neopixel -import time -from csv import reader -import sys - -# helper function for chunking -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i:i+n] - -# sleep_time = 0.033 # approx 30fps -sleep_time = 0.017 # approx 60fps - -NUMBEROFLEDS = 500 -pixels = neopixel.NeoPixel(board.D18, NUMBEROFLEDS, auto_write=False) - -csvFile = sys.argv[1] - - -# read the file -# iterate through the entire thing and make all the points the same colour -lightArray = [] - - -with open(csvFile, 'r') as read_obj: - # pass the file object to reader() to get the reader object - csv_reader = reader(read_obj) - - # Iterate over each row in the csv using reader object - lineNumber = 0 - for row in csv_reader: - # row variable is a list that represents a row in csv - # break up the list of rgb values - # remove the first item - if lineNumber > 0: - parsed_row = [] - row.pop(0) - chunked_list = list(chunks(row, 3)) - for element_num in range(len(chunked_list)): - # this is a single light - r = float(chunked_list[element_num][0]) - g = float(chunked_list[element_num][1]) - b = float(chunked_list[element_num][2]) - light_val = (g, r, b) - # turn that led on - parsed_row.append(light_val) - - # append that line to lightArray - lightArray.append(parsed_row) - # time.sleep(0.03) - - lineNumber += 1 - -print("Finished Parsing") - - - -# run the code on the tree -while True: - f = 0 - for frame in lightArray: - print("running frame " + str(f)) - LED = 0 - while LED < NUMBEROFLEDS: - pixels[LED] = frame[LED] - LED += 1 - pixels.show() - - f += 1 -# time.sleep(sleep_time) + + +# 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.", +) + +# change if your setup has a different number of LEDs +NUMBER_OF_LEDS = 500 + + +def parse_animation_csv( + csv_path, +) -> Tuple[List[List[Tuple[float, float, float]]], List[float]]: + """ + Parse a CSV animation file into python objects. + + :param csv_path: The path to the csv animation file + :return: A list LED colours per frame (GRB) and a list of times for each frame + """ + # 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 "GRB") + 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]) for frame_data in data] + else: + # if the frame time column is not defined default to 60fps + frame_times = [1 / 60] * 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 + ] + print("Finished Parsing") + return frames, frame_times + + +def run(): + args = parser.parse_args() + csv_path = args.csv_path + + frames, frame_times = parse_animation_csv(csv_path) + + pixels = neopixel.NeoPixel(board.D18, NUMBER_OF_LEDS, auto_write=False) + + # run the code on the tree + while True: + for frame_index, (frame, frame_time) in enumerate(zip(frames, frame_times)): + t = time.time() + print(f"running frame {frame_index}") + for led in range(NUMBER_OF_LEDS): + pixels[led] = frame[led] + pixels.show() + sleep_time = frame_time - (time.time() - t) + if sleep_time > 0: + time.sleep(sleep_time) + + +if __name__ == "__main__": + run() From b2a9ac00c81a5dd6393efac7be1c38bdb4ec20fe Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 11:41:48 +0000 Subject: [PATCH 03/13] Added a readme for the animation csv format --- 02_sequencing/README.md | 24 ++++++++++++++++++++++++ README.md | 3 +++ 2 files changed, 27 insertions(+) create mode 100644 02_sequencing/README.md diff --git a/02_sequencing/README.md b/02_sequencing/README.md new file mode 100644 index 0000000..86384e5 --- /dev/null +++ b/02_sequencing/README.md @@ -0,0 +1,24 @@ +#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. +This should contain floats eg 0.03333 is 1/30th of a second. +This column is optional. If undefined will default to 1/30th of a second. + +`[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. + +⚠️Note that the old python running code has a number of limitations. +If you are getting errors running a CSV animation file with run.py make sure you have the latest version which removes all of these limitations. diff --git a/README.md b/README.md index 5fbdb0b..29e4030 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ A version of the final outcome from our test tree is provided in file `coords_ad Questions, suggestions and PRs can be directed to [@range-et](https://github.com/range-et)! +## Animation file format +The specification for the animation CSV file format [can be found here](02_sequencing/README.md) + ## Sequencing Once 3D coordinates are available, the following C# Scripts can be used as inspiration to generate sequences: From a533a7837714f1377ff56c149a814bcd17b05df1 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 11:57:25 +0000 Subject: [PATCH 04/13] Cleaned up flush.py and turnon.py --- 03_execution/flush.py | 17 +++++++++++------ 03_execution/turnon.py | 26 +++++++++++++++----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/03_execution/flush.py b/03_execution/flush.py index d3ef2c3..93888db 100644 --- a/03_execution/flush.py +++ b/03_execution/flush.py @@ -2,12 +2,17 @@ import board import neopixel -import time -NUMBEROFLEDS = 500 -pixels = neopixel.NeoPixel(board.D18, NUMBEROFLEDS) -for x in range(NUMBEROFLEDS): - pixels[x] = (0,0,0) +def main(): + number_of_leds = 500 + pixels = neopixel.NeoPixel(board.D18, number_of_leds) -print("Done") \ No newline at end of file + for led in range(number_of_leds): + pixels[led] = (0, 0, 0) + + print("Done") + + +if __name__ == '__main__': + main() diff --git a/03_execution/turnon.py b/03_execution/turnon.py index d84c0ee..9c0fa80 100644 --- a/03_execution/turnon.py +++ b/03_execution/turnon.py @@ -1,19 +1,23 @@ # Based on code from https://github.com/standupmaths/xmastree2020 +import time +import sys + import board import neopixel -import time -import sys -NUMBEROFLEDS = 500 -pixels = neopixel.NeoPixel(board.D18, NUMBEROFLEDS) -ids = sys.argv[1:] +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") -for id in ids: - i = int(id) - pixels[i] = (255,255,255) - time.sleep(1) - -print("Done") +if __name__ == '__main__': + main() From 1271d982e8c9285b345ba01325b63855e7faa260 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 11:59:38 +0000 Subject: [PATCH 05/13] Cleaned up run.py a bit more Changed parser to parse_known_args to allow other parsers to parse the other arguments. Split up the running code from the CLI code. --- 03_execution/run.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/03_execution/run.py b/03_execution/run.py index 564e40d..4b1046a 100644 --- a/03_execution/run.py +++ b/03_execution/run.py @@ -8,16 +8,6 @@ import board import neopixel - -# 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.", -) - # change if your setup has a different number of LEDs NUMBER_OF_LEDS = 500 @@ -94,10 +84,7 @@ def parse_animation_csv( return frames, frame_times -def run(): - args = parser.parse_args() - csv_path = args.csv_path - +def load_and_run_csv(csv_path): frames, frame_times = parse_animation_csv(csv_path) pixels = neopixel.NeoPixel(board.D18, NUMBER_OF_LEDS, auto_write=False) @@ -115,5 +102,20 @@ def run(): time.sleep(sleep_time) +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__": - run() + main() From b7626d96e5230ccb4ced8a280b2dcbb13779bc2a Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 13:40:58 +0000 Subject: [PATCH 06/13] If the FRAME_TIME column is not defined run as fast as the hardware will allow --- 03_execution/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/03_execution/run.py b/03_execution/run.py index 4b1046a..6d730d8 100644 --- a/03_execution/run.py +++ b/03_execution/run.py @@ -63,8 +63,8 @@ def parse_animation_csv( frame_time_column = header_indexes.pop("FRAME_TIME") frame_times = [float(frame_data[frame_time_column]) for frame_data in data] else: - # if the frame time column is not defined default to 60fps - frame_times = [1 / 60] * len(data) + # if the frame time column is not defined then run as fast as possible like the old code. + frame_times = [0] * len(data) frames = [ [ From 107ed46bfad3ce8b051a0462d08f8a98b1450cf0 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 14:57:29 +0000 Subject: [PATCH 07/13] Reformatted with black --- 03_execution/flush.py | 2 +- 03_execution/turnon.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/03_execution/flush.py b/03_execution/flush.py index 93888db..1346bde 100644 --- a/03_execution/flush.py +++ b/03_execution/flush.py @@ -14,5 +14,5 @@ def main(): print("Done") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/03_execution/turnon.py b/03_execution/turnon.py index 9c0fa80..e1b6535 100644 --- a/03_execution/turnon.py +++ b/03_execution/turnon.py @@ -19,5 +19,5 @@ def main(): print("Done") -if __name__ == '__main__': +if __name__ == "__main__": main() From 6fa905d87c5a63bb2bbad121318005f346109394 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 14:57:53 +0000 Subject: [PATCH 08/13] Moved comments The print every frame may have been slowing it down a bit so I removed this. --- 03_execution/run.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/03_execution/run.py b/03_execution/run.py index 6d730d8..1b773ba 100644 --- a/03_execution/run.py +++ b/03_execution/run.py @@ -80,12 +80,12 @@ def parse_animation_csv( # for each frame in the data for frame_data in data ] - print("Finished Parsing") return frames, frame_times def load_and_run_csv(csv_path): frames, frame_times = parse_animation_csv(csv_path) + print("Finished Parsing") pixels = neopixel.NeoPixel(board.D18, NUMBER_OF_LEDS, auto_write=False) @@ -93,7 +93,6 @@ def load_and_run_csv(csv_path): while True: for frame_index, (frame, frame_time) in enumerate(zip(frames, frame_times)): t = time.time() - print(f"running frame {frame_index}") for led in range(NUMBER_OF_LEDS): pixels[led] = frame[led] pixels.show() From 6818da24e039629dc4845f418f69ba9158fe2947 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 18:58:41 +0000 Subject: [PATCH 09/13] Updated run-folder.py Rewritten to parse the CSV correctly and support variable FRAME_TIME The lerping code had to be rewritten because the old way did not support variable frame times. The new way just interpolates between the last and first frames if they are different enough. Moved the CLI inputs to argparse --- 03_execution/run-folder.py | 363 +++++++++++++++++++++---------------- 1 file changed, 204 insertions(+), 159 deletions(-) diff --git a/03_execution/run-folder.py b/03_execution/run-folder.py index 0892644..ada7a46 100644 --- a/03_execution/run-folder.py +++ b/03_execution/run-folder.py @@ -1,166 +1,211 @@ # Based on code from https://github.com/standupmaths/xmastree2020 +from typing import List, Tuple, Optional +import time +import csv +import os +import argparse + import board import neopixel -import time -from csv import reader -import sys -import os -# sleep_time = 0.033 # approx 30fps -sleep_time = 0.017 # approx 60fps -NUMBEROFLEDS = 500 -LOOPS_PER_SEQUENCE = 5 -TRANSITION_FRAMES = 60 - -if len(sys.argv) > 2: - LOOPS_PER_SEQUENCE = int(sys.argv[2]) -if len(sys.argv) > 3: - TRANSITION_FRAMES = int(sys.argv[3]) - -print("Sequences will loop " + str(LOOPS_PER_SEQUENCE) + " times") -print("Sequences will blend over " + str(TRANSITION_FRAMES) + " frames") - - - - - -# helper function for chunking -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i:i+n] - -# Given two light frames and a tween ratio, returns an interpolated one -def tween_frames(frame_a, frame_b, ratio): - # sanity - len_a = len(frame_a) - len_b = len(frame_b) - if len_a != len_b: - print("cannot interpolate frames with different lengths") - return frame_a - - tween = [] - for i in range(0, len_a): - g = round((1 - ratio) * frame_a[i][0] + ratio * frame_b[i][0]) - r = round((1 - ratio) * frame_a[i][1] + ratio * frame_b[i][1]) - b = round((1 - ratio) * frame_a[i][2] + ratio * frame_b[i][2]) - tween.append((g, r, b)) # remember that LEDs take GRB values - - return tween - -# Given two sequences, returns the second one with the staring block -# blended with the end of the first one -def blend_lights(lights_a, lights_b, transition_length): - # Blend the last and first blocks of lights - end_a = lights_a[len(lights_a) - transition_length:] - # print(str(len(end_a))) - start_b = lights_b[:transition_length] - # print(str(len(start_b))) - - blend = [] - step = 1.0 / (transition_length + 1) - for i in range(0, transition_length): - n = step * (i + 1) - frame = tween_frames(end_a[i], start_b[i], n) - blend.append(frame) - - return blend + lights_b[transition_length:] - - - -# Given a filename, returns the parsed light object -def getLights(csvFile): - print("Parsing " + csvFile) - # read the file - # iterate through the entire thing and make all the points the same colour - lightArray = [] - - with open(csvFile, 'r') as read_obj: +NUMBER_OF_LEDS = 500 + + +def parse_animation_csv( + csv_path, +) -> Tuple[List[List[Tuple[float, float, float]]], List[float]]: + """ + Parse a CSV animation file into python objects. + + :param csv_path: The path to the csv animation file + :return: A list LED colours per frame (GRB) and a list of times for each frame + """ + # 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 = reader(read_obj) - - # Iterate over each row in the csv using reader object - lineNumber = 0 - for row in csv_reader: - # row variable is a list that represents a row in csv - # break up the list of rgb values - # remove the first item - if lineNumber > 0: - parsed_row = [] - row.pop(0) - chunked_list = list(chunks(row, 3)) - for element_num in range(len(chunked_list)): - # this is a single light - r = float(chunked_list[element_num][0]) - g = float(chunked_list[element_num][1]) - b = float(chunked_list[element_num][2]) - light_val = (g, r, b) # these LED lights take GRB color for some reason! - # turn that led on - parsed_row.append(light_val) - - # append that line to lightArray - lightArray.append(parsed_row) - - lineNumber += 1 - - #print("Finished Parsing file " + csvFile) - - return lightArray - - -# Given a light sequence, it plays it on the tree -def playLights(neop, lights): - f = 0 - for frame in lights: - LED = 0 - while LED < NUMBEROFLEDS: - neop[LED] = frame[LED] - LED += 1 - neop.show() - f += 1 - - - - -# Init the neopixel -pixels = neopixel.NeoPixel(board.D18, NUMBEROFLEDS, auto_write=False) - -# get foldername from CLI arguments -folder_path = sys.argv[1] - -# load csv files -csv_files = [] -for file in os.listdir(folder_path): - if file.endswith(".csv"): - csv_files.append(file) - - -# Parse all the sequences at the beginning (its a heavy process for the pi) -sequences = [] -for file in csv_files: - full_path = os.path.join(folder_path, file) - if os.path.isfile(full_path): - lights = getLights(full_path) - sequences.append(lights) - -# Play sequences in a loop -while True: - id = 0 - for lights in sequences: - prev = sequences[id - 1] - print("Playing file " + csv_files[id]) - for i in range(0, LOOPS_PER_SEQUENCE): - lights_now = [] - if i != (LOOPS_PER_SEQUENCE - 1): - print("Loop " + str(i + 1) + " from " + str(LOOPS_PER_SEQUENCE), end='\r') - if i == 0: - lights_now = blend_lights(prev, lights, TRANSITION_FRAMES) - else: - lights_now = lights + 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 "GRB") + 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]) 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 frames, frame_times + + +def draw_frame(pixels, frame, frame_time): + t = time.time() + for led in range(NUMBER_OF_LEDS): + pixels[led] = frame[led] + pixels.show() + sleep_time = frame_time - (time.time() - t) + if sleep_time > 0: + time.sleep(sleep_time) + + +def draw_frames(pixels, frames, frame_times): + """Draw a series of frames to the tree.""" + for frame, frame_time in zip(frames, frame_times): + draw_frame(pixels, frame, frame_time) + + +def draw_lerp_frames(pixels, last_frame, next_frame, transition_frames): + """Interpolate between two frames and draw the result.""" + 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, + ) + + +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 = [] + sequences = [] + 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) + except Exception as e: + print(f"Failed loading spreadsheet {file_name}.\n{e}") else: - print("Loop " + str(i + 1) + " from " + str(LOOPS_PER_SEQUENCE)) - lights_now = lights[:len(lights)-TRANSITION_FRAMES] - - playLights(pixels, lights_now) - - id += 1 + # 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) + + 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() From a10aba75ca250285367e9109d37d6c20d3dbd890 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Mon, 27 Dec 2021 19:12:21 +0000 Subject: [PATCH 10/13] Updated the format readme --- 02_sequencing/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/02_sequencing/README.md b/02_sequencing/README.md index 86384e5..943ccd1 100644 --- a/02_sequencing/README.md +++ b/02_sequencing/README.md @@ -20,5 +20,13 @@ This column is optional. If undefined will default to 1/30th of a second. 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. -⚠️Note that the old python running code has a number of limitations. +### Warning ⚠️ +The old running code has a number of limitations including assuming the position of columns. + +To guarantee that your spreadsheet is read correctly the first column should be `FRAME_ID` or `FRAME_TIME`. + +The remaining columns should be `R_0`,`G_0`,`B_0`,`R_1`,`G_1`,`B_1`,`R_2`,`G_2`,`B_2`,`R_3`,`G_3`,`B_3`, ... + +There should be no more columns after `B_499` + If you are getting errors running a CSV animation file with run.py make sure you have the latest version which removes all of these limitations. From 3368a0551a29fbab5f24f7c19ae83a7bc7f58fde Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Tue, 28 Dec 2021 12:20:40 +0000 Subject: [PATCH 11/13] Split reused functions into a module and cleaned up a bit Moved the CSV parsing code into run_utils.py Added better typing and missing typing Added some docstrings Switched to RGB order like in #6 --- 03_execution/run-folder.py | 121 +++--------------------------- 03_execution/run.py | 91 ++-------------------- 03_execution/run_utils.py | 150 +++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 197 deletions(-) create mode 100644 03_execution/run_utils.py diff --git a/03_execution/run-folder.py b/03_execution/run-folder.py index ada7a46..f8316c8 100644 --- a/03_execution/run-folder.py +++ b/03_execution/run-folder.py @@ -1,119 +1,16 @@ # Based on code from https://github.com/standupmaths/xmastree2020 +# Modified heavily by gentlegiantJGC -from typing import List, Tuple, Optional -import time -import csv +from typing import List import os import argparse import board import neopixel -NUMBER_OF_LEDS = 500 - +from run_utils import parse_animation_csv, draw_frames, draw_lerp_frames, Sequence -def parse_animation_csv( - csv_path, -) -> Tuple[List[List[Tuple[float, float, float]]], List[float]]: - """ - Parse a CSV animation file into python objects. - - :param csv_path: The path to the csv animation file - :return: A list LED colours per frame (GRB) and a list of times for each frame - """ - # 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 "GRB") - 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]) 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 frames, frame_times - - -def draw_frame(pixels, frame, frame_time): - t = time.time() - for led in range(NUMBER_OF_LEDS): - pixels[led] = frame[led] - pixels.show() - sleep_time = frame_time - (time.time() - t) - if sleep_time > 0: - time.sleep(sleep_time) - - -def draw_frames(pixels, frames, frame_times): - """Draw a series of frames to the tree.""" - for frame, frame_time in zip(frames, frame_times): - draw_frame(pixels, frame, frame_time) - - -def draw_lerp_frames(pixels, last_frame, next_frame, transition_frames): - """Interpolate between two frames and draw the result.""" - 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, - ) +NUMBER_OF_LEDS = 500 def run_folder(folder_path: str, loops_per_sequence: int, transition_frames: int): @@ -123,14 +20,14 @@ def run_folder(folder_path: str, loops_per_sequence: int, transition_frames: int 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 = [] - sequences = [] + 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) + sequence = parse_animation_csv(full_path, NUMBER_OF_LEDS, "RGB") except Exception as e: print(f"Failed loading spreadsheet {file_name}.\n{e}") else: @@ -141,7 +38,9 @@ def run_folder(folder_path: str, loops_per_sequence: int, transition_frames: int print("Finished loading animation spreadsheets.") # Init the neopixel - pixels = neopixel.NeoPixel(board.D18, NUMBER_OF_LEDS, auto_write=False) + pixels = neopixel.NeoPixel( + board.D18, NUMBER_OF_LEDS, auto_write=False, pixel_order=neopixel.RGB + ) last_frame = None diff --git a/03_execution/run.py b/03_execution/run.py index fc98614..49dc9bc 100644 --- a/03_execution/run.py +++ b/03_execution/run.py @@ -1,93 +1,19 @@ # Based on code from https://github.com/standupmaths/xmastree2020 +# Modified heavily by gentlegiantJGC -from typing import List, Tuple, Optional -import time -import csv 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 parse_animation_csv( - csv_path, channel_order="RGB" -) -> Tuple[List[List[Tuple[float, float, float]]], List[float]]: - """ - Parse a CSV animation file into python objects. - - :param csv_path: The path to the csv animation file - :return: A list LED colours per frame (GRB) and a list of times for each frame - """ - # 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]) 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 frames, frame_times - - def load_and_run_csv(csv_path): - frames, frame_times = parse_animation_csv(csv_path, "RGB") + frames, frame_times = parse_animation_csv(csv_path, NUMBER_OF_LEDS, "RGB") print("Finished Parsing") pixels = neopixel.NeoPixel( @@ -96,14 +22,7 @@ def load_and_run_csv(csv_path): # run the code on the tree while True: - for frame_index, (frame, frame_time) in enumerate(zip(frames, frame_times)): - t = time.time() - for led in range(NUMBER_OF_LEDS): - pixels[led] = frame[led] - pixels.show() - sleep_time = frame_time - (time.time() - t) - if sleep_time > 0: - time.sleep(sleep_time) + draw_frames(pixels, frames, frame_times) def main(): diff --git a/03_execution/run_utils.py b/03_execution/run_utils.py new file mode 100644 index 0000000..b5e639f --- /dev/null +++ b/03_execution/run_utils.py @@ -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]) 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.time() + for led in range(pixels.n): + pixels[led] = frame[led] + pixels.show() + sleep_time = frame_time - (time.time() - t) + if sleep_time > 0: + time.sleep(sleep_time) + + +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, + ) From 1fe2ba4d64fbd78d6aad297d03c9ed24be5c4c85 Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Tue, 28 Dec 2021 12:31:55 +0000 Subject: [PATCH 12/13] Switched the FRAME_TIME column to milliseconds --- 02_sequencing/README.md | 8 ++++---- 03_execution/run_utils.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/02_sequencing/README.md b/02_sequencing/README.md index 943ccd1..d410765 100644 --- a/02_sequencing/README.md +++ b/02_sequencing/README.md @@ -12,9 +12,9 @@ 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. -This should contain floats eg 0.03333 is 1/30th of a second. -This column is optional. If undefined will default to 1/30th of a second. +`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. @@ -23,7 +23,7 @@ The values of these columns should be floats or ints between 0 and 255 inclusive ### Warning ⚠️ The old running code has a number of limitations including assuming the position of columns. -To guarantee that your spreadsheet is read correctly the first column should be `FRAME_ID` or `FRAME_TIME`. +To guarantee that your spreadsheet is read correctly the first column should be `FRAME_ID` or `FRAME_TIME`. (You can't have both) The remaining columns should be `R_0`,`G_0`,`B_0`,`R_1`,`G_1`,`B_1`,`R_2`,`G_2`,`B_2`,`R_3`,`G_3`,`B_3`, ... diff --git a/03_execution/run_utils.py b/03_execution/run_utils.py index b5e639f..656d078 100644 --- a/03_execution/run_utils.py +++ b/03_execution/run_utils.py @@ -70,7 +70,7 @@ def parse_animation_csv( # 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]) for frame_data in data] + 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) From 31430edb4b7c2afec4bfe8cec5935b195b69e2ec Mon Sep 17 00:00:00 2001 From: gentlegiantJGC Date: Tue, 28 Dec 2021 21:47:46 +0000 Subject: [PATCH 13/13] Added more accurate sleep code time.sleep on windows is limited to 10ms if a time is given. If given 0 it doesn't have the 10ms limit and is still able to yield to other threads --- 03_execution/run_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/03_execution/run_utils.py b/03_execution/run_utils.py index 656d078..796aed8 100644 --- a/03_execution/run_utils.py +++ b/03_execution/run_utils.py @@ -100,13 +100,13 @@ def draw_frame(pixels: neopixel.NeoPixel, frame: Frame, frame_time: float): :param frame: The frame to draw :param frame_time: The time this frame should remain on the device """ - t = time.time() + t = time.perf_counter() for led in range(pixels.n): pixels[led] = frame[led] pixels.show() - sleep_time = frame_time - (time.time() - t) - if sleep_time > 0: - time.sleep(sleep_time) + 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):