Overview
A local production company is working on filming the first of a three-part sci-fi movie and needed a piece of scientific equipment for a laboratory scene. The executive producer/director found an obsolete flow cytometer analyzer in a government surplus sale, winning the bid for US$12. The device had the potential to look like a working DNA synthesizer with the addition of lighting and a bit of animation.
In its day, the analyzer was a high-quality device that was robustly built to provide exceptional mechanical stability for its sensitive optical components. It was therefore quite heavy in spite of its size, requiring at least two persons to lift and position, which would increase the challenge to modify for use in the film. It was not a typical theatrical prop made from foam and balsa wood, for certain.
IĀ was tasked with installing color lighting to enhance the deviceās operational appearance for its brief appearance on-screen. To achieve this, I devised a plan to incorporate several NeoPixel LED strips, which would be controlled by a CircuitPython-based microcontroller, such as the AdafruitĀ M4 Express Feather. The multi-colored NeoPixel LEDs could be strategically positioned both within and outside the device, thereby providing ambient illumination and symbolizing various functions, including sample loading and the incubation process.
Given that the initial device employed industrial-grade servos (specifically, three IMS MDI-17 Drive Plus Motion Control motors) for sample positioning and operating the sample fluid āsipperā needle, there was a preliminary aspiration to incorporate robotic physical movements beyond the lighting sequence. However, this objective was deferred due to the imminent project deadline, so a short puppetry cable would likely be attached to the sample positioning cam to animate movement of the test tube rack.
Project Objective
Build a realistic theatrical prop for a science-fiction film. Animate an obsolete scientific laboratory device to make it appear fully functional and slightly ominous.
Ā Requirements:
- Interior lighting with minor animation for sample processing
- Colored status lights and zone lighting changes to represent process states
- Disguise original make/model with updated logos and graphics
- Tethered remote control of process lighting states
- Manual adjustment of zone brightness levels for location and camera sensitivity
- Self-contained lighting controller with no external circuitry or power supply
- Remove unnecessary components and modules to reduce weight
Secondary requirements (nice to have):
- Hide lighting strip pixels from direct camera view
- Automated sample chamber physical movement during loading
Analyzer Lighting Design
The initial phase of the design process involved identifying the sections of the device that would benefit from illumination. The zones became the sample tube rack loading area near the fluid sipper, the processing area located just behind the sample loading area, the existing process stoplight indicator LEDs (red, yellow, green) mounted within the sample area, the sample tube rack uplight that would assist in illuminating test tube fluid, and the general interior of the device that was visible when the fluid container door was open. These lighting zones were subsequently designated as follows:
- MODE, Zone 0 (process stoplight indicators)
- INT, Zone 1 (device interior)
- CAVE, Zone 2 (sample processing area)
- CELL, Zone 3 (sample tube rack loading area)
- BEAM, Zone 4 (sample tube rack uplight)
Following the identification of the lighting zones, a temporary NeoPixel strip was installed in each zone to assess the quality of illumination, select color schemes, and test camera angles. No permanent installations were made during this phase.
Subsequently, the analyzer underwent a thorough cleaning process, and all unnecessary modules and devices were removed from its interior to reduce bulk and weight. Only components that would not be visible on camera were removed, including the four-color laser light source, precision light bench, sensor data acquisition subsystem, laboratory air and waste subsystems including the fluid pump, and high-voltage power supplies. The existing fluid piping, sensors, power supply, servo control board, and internal minicomputerĀ were retained for potential future modifications.
By removing the superfluous items from the analyzer, its weight was significantly reduced, facilitating easier handling and enabling it to be lifted by a single individual. This reduction also provided ample space for the installation of electronic circuitry, wiring, a lighting power supply, and the NeoPixel LED light strips. During this phase, the final light strip configuration was installed in each zone, utilizing 1/8ā acrylic rods to support the strips in areas without existing supports.
Starting with the interior, two strips were positioned in the top and back sections of the instrument, and near the fluid container door. A few pixels of one strip would also be utilized to illuminate the sample process area. A shortened strip was mounted within the sample tube rack area, and a 32-pixel NeoPixel FeatherWing was positioned beneath the sample tube rack area to function as the uplight illuminator, both attached with pressure sensitive tape. The three existing process stoplight LEDs were connected to a NeoRGB Stemma breakout module for integration into the lighting stripĀ network.
The lighting strip network comprises all NeoPixel strips into a singular serial string of 122 sequential pixels, each capable of independent addressing to support a 24-bit color spectrum. To facilitate daisy-chained network connectivity, the 3-pin connector for each strip was replaced with a 4-pin DuPont-style connector for the stripās 5-volt power, ground, data input, and data output. The lighting control board distributes the pixel data from the MCU to each strip within the network and establishes a direct connection to the 5-volt, 10-ampere lighting power supply.
The initial pixel in the network is the NeoRGB Stemma breakout module, which is utilized for controlling the red, yellow, and green LEDs of the process stoplight. The breakout module is connected to a FeatherWing Proto board, which is attached to the Feather Tripler along with the MCU board. Beyond controlling the stoplight LEDs, the Stemma module receives the 3-volt logic data input signal from the MCU and provides a 5-volt logic output for the remaining NeoPixel lighting strips and NeoPixel FeatherWing.
Lighting Control Panel and Remote Control
The lighting control panel was designed to manually control the brightness of each zone and initiate analyzer processing states such as sleep, sample load, and analysis states. The control panel is mounted on the rear of the analyzer, concealing the controls and indicators from the camera. Manual control of zone brightness is crucial because scene ambient lighting levels are uncertain prior to filming and may require adjustment based on conditions and the cameraās brightness and color response.
The panel was designed to provide brightness control for the interior cell, cave, simple loading area, and sample up-light. An additional control was incorporated on the panel to select a color for the up-light, enabling the choice of a color to enhance the glow of the colored test tube fluid.
In addition to the variable brightness controls, push button controls to initiate and terminate animation processes and reset the MCU were integrated into the rear panel design. The start push button switch was also wired to a quarter-inch TRS jack for the tethered remote control push button. The remote control push button was designed for the protagonist or a concealed operator to advance the processing state. A small cable was attached to a cam in the sample tube rack positioning system, allowing a concealed operator to manually animate rack movement during test tube loading.
Power to the analyzer lighting system is controlled by a power switch mounted on the rear panel. The switch connects the output of a 5-volt, 50-watt power supply to the lighting circuitry and MCU.
Software Design
The primary requirement of theĀ CircuitPython code is to individually control each separate zone of NeoPixels in accordance with the parameters for each process state, while simultaneously monitoring the back panel controls and controlling lighting animations. To achieve this, three classes were developed. The first class, named Pixels, enumerates the NeoPixel addresses for each zone. The second class, State, defines the process states and contains a state parameter dictionary that describes the color and brightness characteristics for the zones, as well as a timeout trigger for each state. The third class, Color, defines the colors and palettes used in each zone and instantiates five PaletteFader instances to control zone brightness.
Following the definition of the classes, the code instantiates all the peripheral devices, including the rear panel controls and switches, the piezo speaker, and an optional OLED Display FeatherWing (which can be stacked on top of the MCU board). The OLED display was utilized solely for troubleshooting purposes and was not incorporated into the final analyzer system. Finally, five instances of the RangeSlicer.Slicer class are instantiated to map the ranges of the back panelās variable controls. The Slicer class maps the values of the analog controls to the normalized values employed for brightness control and color palette selection. The Slicer class incorporates directional hysteresis to enhance output value stability by mitigating analog control noise.
Within the code, synchronous functions include zone filling routines, the piezo tone generator, an RGB tuple to 24-bit hexadecimal value converter, and a sequential advancement function to change to the next process state. Asynchronous functions are utilized for reading and responding to changes in the back panel controls, monitoring state transitions, two lighting animation routines, and status reporting. The primary processing loop code resides in the asynchronous main module, which identifies and executes the asynchronous functions.
Hints and Tricks
- Implement the NeoRGB Stemma breakout to enable precise LED control and, as the initial pixel of the network, provide a 5V voltage adjustment for the subsequent NeoPixel strips.
- Utilize a Piezo Driver Amp for the piezo speaker, particularly when it is integrated into a device enclosure.
- Employ 1/8ā acrylic rods (https://www.tapplastics.com/product/plastics/plastic_rods_tubes_shapes/acrylic_rods/147) to secure and suspend the NeoPixel strips within the device.
- Connect a four-pin DuPont-style connector that incorporates DataIn and DataOut signals to each NeoPixel strip to facilitate the daisy-chained lighting network.
- The control panel layout and graphics were created using the Make the Sandwich Label technique.
# SPDX-FileCopyrightText: 2025 JG for Cedar Grove Maker Studios
# SPDX-License-Identifier: MIT
"""
analyzer/code.py
ver 1.0 2025-11-09 11:16PM
A custom scientific laboratory equipment theatrical prop lighting controller
built into a stripped flow cytometry analyzer.
For the Adafruit M4 Express Feather and optional OLED display.
CedarGrove PaletteFader and RangeSlicer are available from the Adafruit
CircuitPython Community Bundle.
NeoPixel strip configuration (daisy-chained)
STRIP Description Pixel Range Offset Length
---------- ------------------- ----------- ------ ------
MODE_LEDS, Stoplight LEDs, [ 0: 0], 0, 1
STRIP_A, Equipment Interior, [ 1: 30], 1, 30
STRIP_B, processing area, [ 31: 53], 31, 23
BEAM, cell uplight, [ 54: 85], 54, 32
STRIP_D, cell chamber, [ 86:122], 86, 37
Lighting zone logical configuration
ZONE Description Pixel Index
----- --------------- -----------------------
MODE, Process LEDs [ 0: 0]
INT, Interior, [ 1: 30], [ 41: 53]
CAVE, Sample Cave, [ 31: 40]
CELL, Sample Chamber, [ 86: 122]
BEAM, Under Cell, [ 54: 85]
"""
import asyncio
import board
import pwmio
import time
import gc
from analogio import AnalogIn
from digitalio import DigitalInOut, Direction, Pull
import neopixel
from i2cdisplaybus import I2CDisplayBus
import adafruit_displayio_sh1107
import displayio
from cedargrove_palettefader.palettefader import PaletteFader
from cedargrove_rangeslicer import Slicer
class Pixels:
# Creates Lists of pixels for each zone
MODE = [0] # A single pixel
INT = [pix for pix in range(1, 30 + 1)] + [pix for pix in range(41, 53 + 1)]
CAVE = [pix for pix in range(31, 40 + 1)]
CELL = [pix for pix in range(86, 122 + 1)]
BEAM = [pix for pix in range(54, 85 + 1)]
class State:
"""
Analyzer process state definitions and parameters.
"""
# Analyzer Process States
TEST = "TEST" # For setting brightness and color
REST = "REST" # Darkened analyzer with heartbeat
WAKE = "WAKE" # Warm the analyzer for operation
IDLE = "IDLE" # Wait for sample
LOAD = "LOAD" # Load the sample
SCAN = "SCAN" # Analyze the sample
ALL = [TEST, REST, WAKE, IDLE, LOAD, SCAN]
"""
State Parameters Dictionary
Define each zone's [brightness, color index] and state timeout parameters
- The brightness value is the factor to apply to the zone's rear panel
control setting
- The color index value is the index of the zone's color palette
- When the beam color index is None, the color is set by the rear panel
beam color control setting
- The state timeout parameter is in seconds; None will hold until the
start button is pressed to change to the next state
Dictionary state definition example:
state mode int cave cell beam timeout
---- : -------- -------- -------- -------- ----------- -------
TEST : [[1.0, 4], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, None], 30],
"""
params = {
TEST: [[1.0, 4], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, None], 30],
REST: [[1.0, 1], [1.0, 1], [0.5, 1], [0.0, 1], [0.0, 1], None],
WAKE: [[1.0, 1], [1.0, 1], [0.5, 1], [0.0, 1], [0.0, 1], 1],
IDLE: [[1.0, 3], [1.0, 1], [1.0, 1], [0.0, 1], [0.0, 1], 2],
LOAD: [[1.0, 2], [1.0, 1], [1.0, 5], [1.0, 5], [1.0, 3], None],
SCAN: [[1.0, 1], [1.0, 1], [1.0, 1], [1.0, 2], [1.0, 1], None],
}
class Color:
"""
Define color values, reference palettes, and brightness-controlled
palette (PaletteFader) objects.
"""
# Some pure colors
RED = 0xFF0000
GREEN = 0x00FF00
YELLOW = 0xFFFF00
BLUE = 0x0000FF
PURPLE = 0xFF00FF
BLACK = 0x000000
WHITE = 0xFFFFFF
# For RGB Stemma-connected Mode LEDs
LED_RED = 0x400000 # RED LED
LED_GRN = 0x004000 # GRN LED
LED_YEL = 0x0000FF # YEL LED
LED_ALL = 0x4040FF # all LEDs ON
ref_palette = displayio.Palette(6)
ref_palette = [BLACK, RED, BLUE, GREEN, PURPLE, WHITE]
mode_palette = displayio.Palette(5)
mode_palette = [BLACK, LED_RED, LED_GRN, LED_YEL, LED_ALL]
# Define the zone pixel PaletteFader palettes
all_colors = [
PaletteFader(mode_palette, 0.0, normalize=False),
PaletteFader(ref_palette, 0.0, normalize=False),
PaletteFader(ref_palette, 0.0, normalize=False),
PaletteFader(ref_palette, 0.0, normalize=False),
PaletteFader(ref_palette, 0.0, normalize=False),
]
# Instantiate OLED Display as REPL if available
displayio.release_displays()
try:
i2c = board.I2C() # uses board.SCL and board.SDA
display_bus = I2CDisplayBus(i2c, device_address=0x3C)
display = adafruit_displayio_sh1107.SH1107(display_bus, width=128, height=76)
except RuntimeError as err:
print("WARNING: display not found:", err)
print("Analyzer will operate without display.")
# Instantiate the piezo buzzer; pin D4
piezo = pwmio.PWMOut(board.D4, duty_cycle=0, frequency=440, variable_frequency=True)
# Instantiate the two control panel push buttons
pb_start = DigitalInOut(board.D0) # RX
pb_start.direction = Direction.INPUT
pb_start.pull = Pull.UP
pb_stop = DigitalInOut(board.D1) # TX
pb_stop.direction = Direction.INPUT
pb_stop.pull = Pull.UP
# Instantiate the OLED start button (for testing)
disp_sw_a = DigitalInOut(board.D9) # OLED button A
disp_sw_a.direction = Direction.INPUT
disp_sw_a.pull = Pull.UP
# Instantiate the five analog inputs (A1 - A5)
analog_in = [
AnalogIn(board.A1),
AnalogIn(board.A2),
AnalogIn(board.A3),
AnalogIn(board.A4),
AnalogIn(board.A5),
]
# Instantiate the five RangeSlicer instances for control value mapping
control = [
Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
out_slice=1 / 100, hyst_factor=0.25,
out_integer=False), # INT brightness
Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
out_slice=1 / 100, hyst_factor=0.25,
out_integer=False), # CAVE brightness
Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
out_slice=1 / 100, hyst_factor=0.25,
out_integer=False), # CELL brightness
Slicer(in_min=400, in_max=65000, out_min=0, out_max=1.0,
out_slice=1 / 100, hyst_factor=0.25,
out_integer=False), # BEAM brightness
Slicer(in_min=400, in_max=65000, out_min=1, out_max=5,
out_slice=1, hyst_factor=0.25,
out_integer=True), # BEAM color idx
]
# Initialize the control previous value history list
knob_old = [
0.0, # INT brightness
0.0, # CAVE brightness
0.0, # CELL brightness
0.0, # BEAM brightness
0.0, # BEAM color index
]
# Instantiate NeoPixel data output pins for pixel strips and on-board status
status = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.05)
status[0] = Color.YELLOW # Startup
pixel_strip = neopixel.NeoPixel(board.D5, 123, brightness=1.0)
def fill_mode(brightness=0.0, color_idx=4):
# Mode: Front Panel Process Traffic Lights; default ALL ON; zone index = 0
status[0] = Color.PURPLE # Busy
Color.all_colors[0].brightness = brightness
for pixel in Pixels.MODE:
pixel_strip[pixel] = Color.all_colors[0].palette[color_idx]
status[0] = Color.BLUE # Normal
def fill_int(brightness=0.0, color_idx=1):
# Interior: Analyzer Interior; default color RED; zone index = 1
status[0] = Color.PURPLE # Busy
Color.all_colors[1].brightness = brightness
for pixel in Pixels.INT:
pixel_strip[pixel] = Color.all_colors[1].palette[color_idx]
status[0] = Color.BLUE # Normal
def fill_cave(brightness=0.0, color_idx=1):
# Cave: Sample Chamber Cave Interior; default color RED; zone index = 2
status[0] = Color.PURPLE # Busy
Color.all_colors[2].brightness = brightness
for pixel in Pixels.CAVE:
pixel_strip[pixel] = Color.all_colors[2].palette[color_idx]
status[0] = Color.BLUE # Normal
def fill_cell(brightness=0.0, color_idx=2):
# Cell: Sample Chamber Exterior; default color BLUE; zone index = 3
status[0] = Color.PURPLE # Busy
Color.all_colors[3].brightness = brightness
for pixel in Pixels.CELL:
pixel_strip[pixel] = Color.all_colors[3].palette[color_idx]
status[0] = Color.BLUE # Normal
def fill_beam(brightness=0.0, color_idx=1):
# Beam: Uplight brightness and color; default color RED; zone index = 4
status[0] = Color.PURPLE # Busy
Color.all_colors[4].brightness = brightness
for pixel in Pixels.BEAM:
pixel_strip[pixel] = Color.all_colors[4].palette[color_idx]
status[0] = Color.BLUE # Normal
def beep(frequency=440, duration=0.5):
# Beep the piezo buzzer
piezo.frequency = frequency
piezo.duty_cycle = int(65535 / 2) # Beep tone on; 50% duty cycle
time.sleep(duration)
piezo.duty_cycle = 0 # Beep tone off
def rgb_tuple_to_hex(value):
# Convert an RGB color tuple to a 24-bit hex value
return (value[0] << 16) | (value[1] << 8) | value[2]
def advance_state():
# Advance to next state; loop to REST state rather than TEST
global STATE
# Advance STATE to next state; loop back to REST
state_index = State.ALL.index(STATE)
state_index += 1
if state_index >= len(State.ALL):
state_index = 1 # loop to REST state
STATE = State.ALL[state_index]
print(f"* new STATE {STATE}")
async def read_zone_controls():
# Watch for state change; read zone controls and update pixels
global STATE, knob_old
beep(523, 0.25) # C6; wake-up
state_timer = time.monotonic() # Initialize state duration timer
new_state = True # Initialize state changed flag
while True:
await asyncio.sleep(0.6)
# Check Start switch; used for state change
if not (pb_start.value and disp_sw_a.value):
status[0] = Color.YELLOW # Waiting for input
beep(523, 0.25) # C6
print(f"* button old: {STATE}")
while not (pb_start.value and disp_sw_a.value):
time.sleep(0.1)
beep(784, 0.25) # G5
advance_state()
new_state = True # State changed flag
state_timer = time.monotonic() # Start the state timeout timer
status[0] = Color.BLUE # Normal
# STATE duration timeouts; used for state change
if State.params[STATE][5] is not None:
if time.monotonic() - state_timer >= State.params[STATE][5]:
print(f"* timeout old: {STATE}")
advance_state()
new_state = True # State changed flag
state_timer = time.monotonic() # Start the state timeout timer
# Read panel zone control knobs
# INT bright, CAVE bright, CELL bright, BEAM bright, BEAM color
knob_value = [
round(control[0].range_slicer(analog_in[0].value)[0], 2),
round(control[1].range_slicer(analog_in[1].value)[0], 2),
round(control[2].range_slicer(analog_in[2].value)[0], 2),
round(control[3].range_slicer(analog_in[3].value)[0], 2),
round(control[4].range_slicer(analog_in[4].value)[0], 2),
]
# Update Zone Brightness and Color Values
# INT Zone Brightness; knob index = 0, zone index = 1
if (knob_value[0] != knob_old[0]) or new_state:
# Update when value or state has changed
knob_old[0] = knob_value[0]
bright_factor, color_idx = State.params[STATE][1] # Get params
fill_int(knob_value[0] * bright_factor, color_idx)
# CAVE Zone Brightness; knob index = 1, zone index = 2
if (knob_value[1] != knob_old[1]) or new_state:
# Update when value or state has changed
knob_old[1] = knob_value[1]
bright_factor, color_idx = State.params[STATE][2] # Get params
fill_cave(knob_value[1] * bright_factor, color_idx)
# CELL Zone Brightness; knob index = 2, zone index = 3
if (knob_value[2] != knob_old[2]) or new_state:
# Update when value or state has changed
knob_old[2] = knob_value[2]
bright_factor, color_idx = State.params[STATE][3] # Get params
fill_cell(knob_value[2] * bright_factor, color_idx)
# BEAM Zone Color; knob index = 4, zone index = 4
if State.params[STATE][4][1] is None:
# BEAM zone color set by control and latest color value
if (knob_value[4] != knob_old[4]) or new_state:
# Update when value or state has changed
knob_old[4] = knob_value[4]
bright_factor, _ = State.params[STATE][4] # Get params
fill_beam(knob_value[3] * bright_factor, knob_value[4])
else:
# BEAM zone color set by state parameter
if new_state:
bright_factor, color_idx = State.params[STATE][4] # Get params
fill_beam(knob_old[3] * bright_factor, color_idx)
# BEAM Zone Brightness; knob index = 3, zone index = 4
if (knob_value[3] != knob_old[3]) or new_state:
# Update when value or state has changed
knob_old[3] = knob_value[3]
bright_factor, _ = State.params[STATE][4] # Get params
if State.params[STATE][4][1] is None:
# BEAM color set by latest control value
color_idx = knob_old[4] # BEAM color knob
else:
# BEAM color is set by state parameter
_, color_idx = State.params[STATE][4] # Get params
fill_beam(knob_value[3] * bright_factor, color_idx)
new_state = False
async def animate_int(delay=1.0):
while True:
await asyncio.sleep(delay)
for pixel in range(43, 45 + 1):
if rgb_tuple_to_hex(pixel_strip[45]) == Color.all_colors[1].palette[1]:
pixel_strip[pixel] = Color.all_colors[1].palette[3]
pixel_strip[pixel + 3] = Color.all_colors[1].palette[1]
else:
pixel_strip[pixel] = Color.all_colors[1].palette[1]
pixel_strip[pixel + 3] = Color.all_colors[1].palette[3]
async def animate_mode(delay=1.5, steps=15):
global STATE
while True:
# Gradually brighten and dim a mode LED
for i in range(int(steps * 0.10), steps + 1):
Color.all_colors[0].brightness = 1.0 * (i / steps)
pixel_strip[0] = Color.all_colors[0].palette[State.params[STATE][0][1]]
await asyncio.sleep(delay / steps)
for i in range(steps, int(steps * 0.10), -1):
Color.all_colors[0].brightness = 1.0 * (i / steps)
pixel_strip[0] = Color.all_colors[0].palette[State.params[STATE][0][1]]
await asyncio.sleep(delay / steps)
async def animate_status():
# Report current state, memory usage, and collect garbage
global STATE
while True:
gc.collect()
print(f"-- {STATE} {time.monotonic():0.0f} sec")
print(f" mem_free: {gc.mem_free() / 1000:3.0f}kb")
await asyncio.sleep(15)
async def main():
# Define the async tasks
animate_int_task = asyncio.create_task(animate_int())
read_ctrls_task = asyncio.create_task(read_zone_controls())
animate_status_task = asyncio.create_task(animate_status())
animate_mode_task = asyncio.create_task(animate_mode())
# Start the async tasks
await asyncio.gather(
read_ctrls_task,
animate_int_task,
animate_status_task,
animate_mode_task,
)
print("async main() done") # Should never reach this point
# ### PRIMARY PROCESS ###
# Establish the start-up state
STATE = State.TEST
# Start the asynchronous task manager
asyncio.run(main())
print("primary process done") # Should never reach this point
This page (Building a Sci-Fi Movie Prop) was last updated on December 31, 2025.
Text editor powered by tinymce.