The CircuitPython code we'll use was designed to display light painting art based upon bitmap images. There are two display modes: looping and one-shot.
Looping will run the image continuously. This is great for streaking images all around, or for "stamping" copies of your image into mid-air.
One-shot images will only display once each time you trigger them by pressing the capacitive touch sensor assigned -- in the case of HalloWing this is pad A2.
CircuitPython Setup
To get started, you'll want to set up your HalloWing or Circuit Playground Express for use with CircuitPython by following this guide for HalloWing or by following this guide for CPX. When you're ready, and can upload code to the board return here.
Adafruit really likes using the Mu editor to edit the CircuitPython code.Ā See this guide on loading and using Mu.
HalloWing Code
You can copy the code here and then paste it into Mu. Save it to your HalloWing asĀ code.py
# SPDX-FileCopyrightText: 2018 John Edgar Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""HalloWing Light Paintbrush"""
# Single images only. Filename is set in code,
# potentiometer is used to tune playback SPEED
# images should be 30px high, up to 100px wide, 24-bit .bmp files
import gc
import time
import board
import touchio
import digitalio
from analogio import AnalogIn
from neopixel_write import neopixel_write
# uncomment one line only here to select bitmap
FILENAME = "bats.bmp" # BMP file to load from flash filesystem
#FILENAME = "digikey.bmp"
#FILENAME = "burger.bmp"
#FILENAME = "afbanner.bmp"
#FILENAME = "blinka.bmp"
#FILENAME = "ghost04.bmp"
#FILENAME = "ghost07.bmp"
#FILENAME = "ghost02.bmp"
#FILENAME = "helix-32x30.bmp"
#FILENAME = "wales2-107x30.bmp"
#FILENAME = "pumpkin.bmp"
#FILENAME = "rainbow.bmp"
#FILENAME = "rainbowRoad.bmp"
#FILENAME = "rainbowZig.bmp"
#FILENAME = "skull.bmp"
#FILENAME = "adabot.bmp"
#FILENAME = "green_stripes.bmp"
#FILENAME = "red_blue.bmp"
#FILENAME = "minerva.bmp"
TOUCH = touchio.TouchIn(board.A2) # Rightmost capacitive touch pad
ANALOG = AnalogIn(board.SENSE) # Potentiometer on SENSE pin
BRIGHTNESS = 1.0 # NeoPixel brightness 0.0 (min) to 1.0 (max)
GAMMA = 2.7 # Adjusts perceived brighthess linearity
NUM_PIXELS = 30 # NeoPixel strip length (in pixels)
LOOP = False #set to True for looping
# Switch off onboard NeoPixel...
NEOPIXEL_PIN = digitalio.DigitalInOut(board.NEOPIXEL)
NEOPIXEL_PIN.direction = digitalio.Direction.OUTPUT
neopixel_write(NEOPIXEL_PIN, bytearray(3))
# ...then assign NEOPIXEL_PIN to the external NeoPixel connector:
NEOPIXEL_PIN = digitalio.DigitalInOut(board.EXTERNAL_NEOPIXEL)
NEOPIXEL_PIN.direction = digitalio.Direction.OUTPUT
neopixel_write(NEOPIXEL_PIN, bytearray(NUM_PIXELS * 3))
def read_le(value):
"""Interpret multi-byte value from file as little-endian value"""
result = 0
shift = 0
for byte in value:
result += byte << shift
shift += 8
return result
class BMPError(Exception):
"""Error handler for BMP-loading function"""
def load_bmp(filename):
"""Load BMP file, return as list of column buffers"""
# pylint: disable=too-many-locals, too-many-branches
try:
print("Loading", filename)
with open("/" + filename, "rb") as bmp:
print("File opened")
if bmp.read(2) != b'BM': # check signature
raise BMPError("Not BitMap file")
bmp.read(8) # Read & ignore file size and creator bytes
bmp_image_offset = read_le(bmp.read(4)) # Start of image data
bmp.read(4) # Read & ignore header size
bmp_width = read_le(bmp.read(4))
bmp_height = read_le(bmp.read(4))
# BMPs are traditionally stored bottom-to-top.
# If bmp_height is negative, image is in top-down order.
# This is not BMP canon but has been observed in the wild!
flip = True
if bmp_height < 0:
bmp_height = -bmp_height
flip = False
print("WxH: (%d,%d)" % (bmp_width, bmp_height))
if read_le(bmp.read(2)) != 1:
raise BMPError("Not single-plane")
if read_le(bmp.read(2)) != 24: # bits per pixel
raise BMPError("Not 24-bit")
if read_le(bmp.read(2)) != 0:
raise BMPError("Compressed file")
print("Image format OK, reading data...")
row_size = (bmp_width * 3 + 3) & ~3 # 32-bit line boundary
# Constrain rows loaded to pixel strip length
clipped_height = min(bmp_height, NUM_PIXELS)
# Allocate per-column pixel buffers, sized for NeoPixel strip:
columns = [bytearray(NUM_PIXELS * 3) for _ in range(bmp_width)]
# Image is displayed at END (not start) of NeoPixel strip,
# this index works incrementally backward in column buffers...
idx = (NUM_PIXELS - 1) * 3
for row in range(clipped_height): # For each scanline...
if flip: # Bitmap is stored bottom-to-top order (normal BMP)
pos = bmp_image_offset + (bmp_height - 1 - row) * row_size
else: # Bitmap is stored top-to-bottom
pos = bmp_image_offset + row * row_size
bmp.seek(pos) # Start of scanline
for column in columns: # For each pixel of scanline...
# BMP files use BGR color order
blue, green, red = bmp.read(3)
# Rearrange into NeoPixel strip's color order,
# while handling brightness & gamma correction:
column[idx] = int(pow(green / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
column[idx+1] = int(pow(red / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
column[idx+2] = int(pow(blue / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
idx -= 3 # Advance (back) one pixel
# Add one more column with no color data loaded. This is used
# to turn the strip off at the end of the painting operation.
if not LOOP:
columns.append(bytearray(NUM_PIXELS * 3))
print("Loaded OK!")
gc.collect() # Garbage-collect now so playback is smoother
return columns
except OSError as err:
if err.args[0] == 28:
raise OSError("OS Error 28 0.25") from err
raise OSError("OS Error 0.5") from err
except BMPError as err:
raise BMPError("Failed to parse BMP: " + err.args[0]) from err
# Load BMP image, return 'COLUMNS' array:
COLUMNS = load_bmp(FILENAME)
print("Mem free:", gc.mem_free())
COLUMN_DELAY = ANALOG.value / 65535.0 / 10.0 # 0.0 to 0.1 seconds
while LOOP:
for COLUMN in COLUMNS:
neopixel_write(NEOPIXEL_PIN, COLUMN)
time.sleep(COLUMN_DELAY)
while True:
# Wait for touch pad input:
while not TOUCH.value:
continue
COLUMN_DELAY = ANALOG.value / 65535.0 / 10.0 # 0.0 to 0.1 seconds
# print(COLUMN_DELAY)
# Play back color data loaded into each column:
for COLUMN in COLUMNS:
neopixel_write(NEOPIXEL_PIN, COLUMN)
time.sleep(COLUMN_DELAY)
# Last column is all 0's, no need to explicitly clear strip
# Wait for touch pad release, just in case:
while TOUCH.value:
continue
Circuit Playground Express Code
If you're using the Circuit Playground Express, copy and paste this code instead.
# SPDX-FileCopyrightText: 2018 John Edgar Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Circuit Playground Express Light Paintbrush"""
# Single images only. Filename is set in code,
# CPX buttons A&B are used to increase and decrease playback speed
# Touch pad A5 to trigger playback in one-shot mode
# Switch on CPX goes from one-shot to looping mode, requires reset
# images should be 30px high, up to 100px wide, 24-bit .bmp files
import gc
import time
import board
import touchio
import digitalio
from digitalio import DigitalInOut, Direction, Pull
from neopixel_write import neopixel_write
# uncomment one line only here to select bitmap
FILENAME = "bats.bmp" # BMP file to load from flash filesystem
#FILENAME = "jpw01.bmp"
#FILENAME = "digikey.bmp"
#FILENAME = "burger.bmp"
#FILENAME = "afbanner.bmp"
#FILENAME = "blinka.bmp"
#FILENAME = "ghost.bmp"
#FILENAME = "helix-32x30.bmp"
#FILENAME = "wales2-107x30.bmp"
#FILENAME = "pumpkin.bmp"
#FILENAME = "rainbow.bmp"
#FILENAME = "rainbowRoad.bmp"
#FILENAME = "rainbowZig.bmp"
#FILENAME = "skull.bmp"
#FILENAME = "adabot.bmp"
#FILENAME = "green_stripes.bmp"
#FILENAME = "red_blue.bmp"
#FILENAME = "minerva.bmp"
TOUCH = touchio.TouchIn(board.A5) # capacitive touch pad
SPEED = 10000
SPEED_ADJUST = 2500 # This value changes the increment for button speed adjustments
BRIGHTNESS = 1.0 # Set brightness here, NOT in NeoPixel constructor
GAMMA = 2.7 # Adjusts perceived brighthess linearity
NUM_PIXELS = 30 # NeoPixel strip length (in pixels)
NEOPIXEL_PIN = board.A1 # Pin where NeoPixels are connected
DELAY_TIME = 0.01 # Timer delay before it starts
LOOP = False # Set to True for looping
# button setup
button_a = DigitalInOut(board.BUTTON_A)
button_a.direction = Direction.INPUT
button_a.pull = Pull.DOWN
button_b = DigitalInOut(board.BUTTON_B)
button_b.direction = Direction.INPUT
button_b.pull = Pull.DOWN
# switch setup
switch = DigitalInOut(board.SLIDE_SWITCH)
switch.direction = Direction.INPUT
switch.pull = Pull.UP
#status led setup
led = DigitalInOut(board.D13)
led.direction = Direction.OUTPUT
if switch.value:
LOOP = True
else:
LOOP = False
# Enable NeoPixel pin as output and clear the strip
NEOPIXEL_PIN = digitalio.DigitalInOut(NEOPIXEL_PIN)
NEOPIXEL_PIN.direction = digitalio.Direction.OUTPUT
neopixel_write(NEOPIXEL_PIN, bytearray(NUM_PIXELS * 3))
def read_le(value):
"""Interpret multi-byte value from file as little-endian value"""
result = 0
shift = 0
for byte in value:
result += byte << shift
shift += 8
return result
class BMPError(Exception):
"""Error handler for BMP-loading function"""
def load_bmp(filename):
"""Load BMP file, return as list of column buffers"""
# pylint: disable=too-many-locals, too-many-branches
try:
print("Loading", filename)
with open("/" + filename, "rb") as bmp:
print("File opened")
if bmp.read(2) != b'BM': # check signature
raise BMPError("Not BitMap file")
bmp.read(8) # Read & ignore file size and creator bytes
bmp_image_offset = read_le(bmp.read(4)) # Start of image data
bmp.read(4) # Read & ignore header size
bmp_width = read_le(bmp.read(4))
bmp_height = read_le(bmp.read(4))
# BMPs are traditionally stored bottom-to-top.
# If bmp_height is negative, image is in top-down order.
# This is not BMP canon but has been observed in the wild!
flip = True
if bmp_height < 0:
bmp_height = -bmp_height
flip = False
print("WxH: (%d,%d)" % (bmp_width, bmp_height))
if read_le(bmp.read(2)) != 1:
raise BMPError("Not single-plane")
if read_le(bmp.read(2)) != 24: # bits per pixel
raise BMPError("Not 24-bit")
if read_le(bmp.read(2)) != 0:
raise BMPError("Compressed file")
print("Image format OK, reading data...")
row_size = (bmp_width * 3 + 3) & ~3 # 32-bit line boundary
# Constrain rows loaded to pixel strip length
clipped_height = min(bmp_height, NUM_PIXELS)
# Allocate per-column pixel buffers, sized for NeoPixel strip:
columns = [bytearray(NUM_PIXELS * 3) for _ in range(bmp_width)]
# Image is displayed at END (not start) of NeoPixel strip,
# this index works incrementally backward in column buffers...
idx = (NUM_PIXELS - 1) * 3
for row in range(clipped_height): # For each scanline...
if flip: # Bitmap is stored bottom-to-top order (normal BMP)
pos = bmp_image_offset + (bmp_height - 1 - row) * row_size
else: # Bitmap is stored top-to-bottom
pos = bmp_image_offset + row * row_size
bmp.seek(pos) # Start of scanline
for column in columns: # For each pixel of scanline...
# BMP files use BGR color order
blue, green, red = bmp.read(3)
# Rearrange into NeoPixel strip's color order,
# while handling brightness & gamma correction:
column[idx] = int(pow(green / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
column[idx+1] = int(pow(red / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
column[idx+2] = int(pow(blue / 255, GAMMA) * BRIGHTNESS * 255 + 0.5)
idx -= 3 # Advance (back) one pixel
# Add one more column with no color data loaded. This is used
# to turn the strip off at the end of the painting operation.
if not LOOP:
columns.append(bytearray(NUM_PIXELS * 3))
print("Loaded OK!")
gc.collect() # Garbage-collect now so playback is smoother
return columns
except OSError as err:
if err.args[0] == 28:
raise OSError("OS Error 28 0.25") from err
raise OSError("OS Error 0.5") from err
except BMPError as err:
raise BMPError("Failed to parse BMP: " + err.args[0]) from err
# Load BMP image, return 'columns' array:
COLUMNS = load_bmp(FILENAME)
print("Mem free:", gc.mem_free())
COLUMN_DELAY = SPEED / 65535.0 / 10.0 # 0.0 to 0.1 seconds
# print(COLUMN_DELAY)
led.value = True
while LOOP:
for COLUMN in COLUMNS:
neopixel_write(NEOPIXEL_PIN, COLUMN)
time.sleep(COLUMN_DELAY)
while True:
# Wait for touch pad input:
# buttons increase and decrease speed of playback
while not TOUCH.value:
if button_a.value:
if SPEED >= SPEED_ADJUST:
led.value = False
SPEED = SPEED - SPEED_ADJUST
COLUMN_DELAY = SPEED / 65535.0 / 10.0 # 0.0 to 0.1 seconds
time.sleep(0.25) #debounce
led.value = True
# print(SPEED)
if button_b.value:
if SPEED < 100000:
led.value = False
SPEED = SPEED + SPEED_ADJUST
COLUMN_DELAY = SPEED / 65535.0 / 10.0 # 0.0 to 0.1 seconds
time.sleep(0.25) #debounce
led.value = True
# print(SPEED)
continue
time.sleep(DELAY_TIME)
# Play back color data loaded into each column:
for COLUMN in COLUMNS:
neopixel_write(NEOPIXEL_PIN, COLUMN)
time.sleep(COLUMN_DELAY)
# Last column is all 0's, no need to explicitly clear strip
# Wait for touch pad release, just in case:
while TOUCH.value:
continue
Image Selection
The are only two sections of code you'll need to adjust: FILENAME variable assignment and the image looping.
By default, it will play the bats.bmp file. If you create your own bitmap namedĀ unicorn.bmp and load it onto the HalloWing, you'll want to adjust the FILENAME to match, such as:
FILENAME = "unicorn.bmp"
If you want your image to loop automatically to create streaks or repeating stamps, change this line:
LOOP = FalseĀ
to this:
LOOP = True
On the next page we'll add images to the board.
Page last edited January 22, 2025
Text editor powered by tinymce.