CC Blog Projects Research & Design Hub

Building a Holographic Persistence-of-Vision Display

FIGURE 1 Three examples of the holographic persistence-of-vision (POV) display. (Note the Circuit Cellar logo in the center.)

Paint Light Into Ethereal Floating Images Using a Raspberry Pi Pico

Persistence of vision (POV) is the human brain’s ability to perceive light for a brief period after it stops entering the eye. These three Cornell University students exploited the POV phenomenon to create the illusion of holographic images by changing the colors of a rapidly rotating series of LEDs.

  • How can I build a holograph?
  • What is a funny project with a Raspberry Pi?
  • What is a project using LEDs?
  • Raspberry Pi Pico

Holograms are a common fixture in science fiction, yet remain somewhat of a unicorn for the tech world. While building a “real” hologram might be out of reach for today’s technology, we can still strap a horn to a metaphorical horse and make it feel pretty. Persistence of Vision (POV) displays offer one method by utilizing a psychological trick to construct floating images out of light.

Persistence of Vision refers to the brain’s tendency to perceive light for a brief period after it stops entering the eye. Through clever engineering, this effect can be exploited to “paint” light onto thin air. A quickly rotating series of LEDs appear to the brain as a full circle, for example, and by changing the colors emitted by the LEDs, we can create the illusion of floating holographic images. These images are ghostly, beautiful, and mesmerizing—perfect for advertising, art installations, or product presentations (Figure 1).

Three examples of the holographic persistence-of-vision (POV) display. (Note the Circuit Cellar logo in the center.)
Three examples of the holographic persistence-of-vision (POV) display. (Note the Circuit Cellar logo in the center.)

The unique design challenges associated with creating a high-speed, fully wireless (both for power and communication), and low-budget POV display led us down many interesting paths in a variety of engineering disciplines. Our electronics harnessed the Raspberry PI Pico microcontroller (MCU) to drive the display, and we created custom printed circuit boards (PCBs) to house the microcontroller, LEDs, and accompanying electronics. The whole system is powered inductively, removing the need for any wires. Our software consists of embedded C programming for high-speed operation of the Pico, along with a Python TCP client to send images to the display over Wi-Fi. Finally, our mechanical design uses 3D-printed components to enable safe, high-RPM operation.


In a system experiencing high accelerations, PCBs are king. Made from high-strength PTFE substrate, these boards can stand many thousands of Gs, and soldered connections are extremely resilient to the characteristic forces of a POV display. They are also lightweight and slightly flexible, making them perfect for our use case. Figure 2 shows the two PCBs we made for our design.

Our PCBs laid out in KiCad. Top: "The arm," which holds 40 surface-mounted APA102 LEDs. Bottom: The control board, which holds the Pico W and the power/logic electronics.
Our PCBs laid out in KiCad. Top: “The arm,” which holds 40 surface-mounted APA102 LEDs. Bottom: The control board, which holds the Pico W and the power/logic electronics.

We call the first PCB “the arm,” shown lit up in Figure 3. The arm holds 40 surface-mounted APA102 LEDs, and provides standard 0.1-inch headers for interfacing with the LEDs. We chose the APA102 LEDs because they use a two-wire SPI protocol to communicate with the control board. This allows communication rates of up to 20MHz, more than fast enough for our application. We previously experimented with the popular WS2812B LEDs, but these LEDs are capped at a 1kHz refresh rate due to their single-wire protocol. This would limit the radial resolution of our display. We added an M3-sized hole on each end of the arm, one to connect the arm to the rest of the rotor, and one to attach weights to balance the system.

"The arm" mounted and with LEDs lit.
“The arm” mounted and with LEDs lit.

The second PCB, shown in Figure 4 is the control board. The control board holds the Pico W and the power/logic electronics to facilitate communication with the LEDs and Hall effect sensor. The Pico W uses 3.3V logic levels, while the APA102 LEDs expect 5V logic. To remedy this disparity, we included a 74AHCT125 Logic Level shifter. This shifter converts our 3.3V signal to 5V, and is fast enough to deal with our 20MHz SPI signals. A 47µF decoupling capacitor is placed across the power supply, which is especially important when dealing with the rapidly changing power requirements of the LEDs. We also added a Schottky diode between the power rail and the Pico’s VSYS pin. This diode allows the board to simultaneously take power from screw terminals and the Pico’s onboard USB without damaging the Pico or the power supply.

The control board PCB and schematic. All PCBs were designed using KiCad, an open-source ECAD software.
The control board PCB and schematic. All PCBs were designed using KiCad, an open-source ECAD software.

To facilitate programming, we connected a push button between the RUN pin and ground. Pulling the RUN pin down causes the Pico to enter boot-select mode and appear as a programmable USB device. Finally, we wired the Hall effect sensor to a GPIO pin of the Pico with a 10kΩ pull-up resistor. Note that the sensor is active low.

One of the key design choices for a POV display is how to power the rotor. Because it spins 360 degrees, wires cannot be safely routed between the stationary stand and mobile rotor. There are three traditional approaches to this issue: a slip ring, an onboard battery, and an inductive power supply.

Slip rings use brushes and contacts to create connections that can “slip” past each other and rotate. However, they are notoriously unreliable, cause sparks under high load, wear over time, and add friction. An onboard battery adds weight to the rotor and is a potential safety concern at high speed. Finally, there are inductive power supplies. Due to the widespread adoption of wireless charging technology, inductive coils are readily available online. They are frictionless, robust, and are by far the “slickest” solution, if that matters (let’s be honest—it does). We picked up a $25 system on Amazon complete with a 5V level converter hardware, shown installed on the arm in Figure 5.

Inductive coil with 5V level converter hardware mounted on the bottom of "the arm." The coil powers the rotor, which spins 360 degrees.
Inductive coil with 5V level converter hardware mounted on the bottom of “the arm.” The coil powers the rotor, which spins 360 degrees.

Finally, we need to spin the rotor. We used a spare motor found around the lab, but most motors will do. Our motor used 18W to achieve 1,800rpm(equivalent to 30fps), so look for something in that range if you build this project yourself. This motor is powered by a motor speed controller built from a second PI Pico and an HBridge. This allowed us to control the motor speed precisely, but a bench supply would also suffice.


We started the design process by working on the rotor. As mentioned in the previous section, the PCBs, themselves, were included in the mechanical construction of the rotor. To supplement the PCBs, we needed to create a superstructure that holds the PCBs together and connects them to the motor shaft. This structure also served to mount the inductive coil. Along with the functional requirements, we want to keep weight to a minimum and make the design modular so that design iterations are faster.

Our design is 3D printed with minimal infill to reduce weight. It is only a couple of millimeters thick, and is designed to use the PCBs to supplement its strength. Components are connected using M3 screws that are threaded directly into the PLA. With proper print settings, these connections are remarkably strong, and more than enough for the mostly lateral load of this application.

To interface with the motor, we created an adapter that fits the motor shaft on one end and supplies a 1” square hole pattern on the other. We made this a separate component, so that we could quickly iterate designs in case the fit on the motor was too loose. The final product is shown in Figure 6.

The fully assembled rotor.
The fully assembled rotor.

The next step was creating a stand to house the motor and inductive coil. The inductive coil has a specific range in which it can operate safely, and we used the stand to enforce this distance. The stand also allows us to clamp the system to a table for testing.

The design is split into two parts to reduce reprinting time. All parts are printed in PLA with 20% infill, which was plenty strong enough for the application. PLA is not ferromagnetic, which means that it does not interfere with the inductive power supply. The motor mount is shown in Figure 7.

The final motor mount.
The final motor mount.

Using just 40 independently addressable LEDs, we were able to create the illusion of 12,000 pixels at over 30fps. The display is 26” in diameter, and updates over Wi-Fi from our custom Python client.

To display an image, we first use a Python program running on a laptop to convert an image into the display’s polar coordinate system. This data gets sent over TCP to the Pico W, where it is prepared to be displayed. We created a browser-based GUI to streamline the process, accepting images or GIFs and handling the full transmission cycle.

The Pico measures its rotational speed using a Hall effect sensor and a magnet mounted to the stand. With this information, it displays the pixels for the “slice” of the image corresponding to its current position in the rotation.

The MCU code executes two processes, each running on its own core. One process handles TCP exchanges and writes the image array with new pixel data. The other process reads the image array and updates the LEDs to maintain a complete image. By using both cores, we can concurrently receive TCP messages and control the LED strip, allowing for seamless operation.

Python Code: Our Python code creates an HTTP server that allows users to submit images to be shown on the display. The front end (shown in Figure 8) uses simple Javascript to POST the user’s image to the server, where the server downloads and caches it.

Figure 8
The front end for the Python code. It uses simple Javascript to POST the user's image to the server, where the server downloads and caches it.
Figure 8
The front end for the Python code. It uses simple Javascript to POST the user’s image to the server, where the server downloads and caches it.

Raster images are typically displayed on rectangular screens, utilizing a rectangular array of pixels. POV displays are unique in the radial arrangement of their pixels, meaning we must pre-process the images from the canonical rectangular system into the display’s native polar coordinates.

Our approach is virtually overlaying the location of the display’s pixels over the rectangular image. We chose to center the circle and have its diameter be the same as the smallest dimension of the source image. This focuses on the central parts of the image and maximizes the amount of the display utilized. Any pixels outside the circle defined by this radius are ignored. For each of the pixels on the display, the closest pixel of the rectangular image is selected, and that color is used for the radial representation.

When processing the image, we must decide on a resolution. Because the number of LEDs on the arm is physically determined, we can only control the angular resolution. This is the number of times LEDs must change color while traveling in one rotation. While higher angular resolution results in a clearer image, it also strains the processing time of the Pico and the refresh rate of the LEDs. Our experimentation showed diminishing returns with >300 LED changes per rotation, so we stuck with that resolution for our final results.

The pixels are then pushed onto an array that stores the polar image. The array stores a pixel as (THETA, R, COLOR) rather than the traditional (X, Y, COLOR). Theta represents which angle of the arm contains the pixel, R represents the distance from the center of the arm in terms of the number of LEDs, and COLOR is an RGB triple.

Figure 9 shows what the processed images look like at various radial resolutions, and Listing 1 shows the Python code for our implementation. We were able to run up to 300 LED changes per rotation, but reasonable images can be generated with 180 changes or less.

A test image produced at angular resolutions of 60, 90, and 180 LED changes per rotation. Angular resolution is the number of times LEDs must change color while traveling in one rotation.
A test image produced at angular resolutions of 60, 90, and 180 LED changes per rotation. Angular resolution is the number of times LEDs must change color while traveling in one rotation.
The Python code for sampling an image in polar coordinates.

# Sample the image at each point that an LED will update at# We calculate this point in polar space, convert it to rectangular, then sample the image at that point# The results are stored in rad_img, which is a polar representation. Essentially (theta, r)for t in range(DIVISIONS_PER_ROTATION):    for l in range(NUM_LEDS):        # What angle are we looking at (in radians)        theta = t * ((2 * numpy.pi) / DIVISIONS_PER_ROTATION) + offset        # How far out are we (from 0 to 1)        r = l / NUM_LEDS        x_raw = numpy.cos(theta) * r        y_raw = numpy.sin(theta) * r        # Get the rectangular coord for the current polar coord, centered on the image and going to the edges        x = numpy.interp(            x_raw, [-1, 1], [(width / 2) - (min_dim / 2), (width / 2) + (min_dim / 2)])        y = numpy.interp(            y_raw, [-1, 1], [(height / 2) - (min_dim / 2), (height / 2) + (min_dim / 2)])        x = int(x)        y = int(y)        assert x < width and x > 0        assert y < height and y > 0        rad_img[t][l] = img_array_np[x][y]

Once the image has been processed, we must send it to the Pico. This is handled using a TCP connection created by the Socket Python module. A laptop running the Python code presents itself as a TCP server, to which the Pico automatically connects. Once the connection is established, we can send our image as a stream of bytes to the Pico.

Pico Code: The Raspberry Pi Foundation provides an excellent SDK for programming the Pico, including all the build tools necessary for deploying code, and a collection of drivers for the various peripherals of the RP2040. Our work makes extensive use of this SDK, along with the popular Protothreads threading library for concurrent programming [1].

The RP2040 included on the Pico is dual-core processor, which is perfect for our use case. The display can be broken down into two high-level components: networking (talking to the laptop over Wi-Fi); and control (controlling the LEDs and ensuring timing consistency). Running each component on a separate core separates the interrupt-heavy and asynchronous requirements of network programming from the timing critical and processor-greedy requirements of peripheral control. Additionally, because the control logic only reads data from shared memory (never writing) there is no concern over race conditions.

Core zero is responsible for the networking code, and starts life by initializing its peripherals. The networking on the Pico W is handled by an onboard CYW43439 chip, which has a handy driver packaged into the Pico SDK. After initializing the RP2040’s GPIO pins, the CYW43439 driver is initialized and used to connect to a provided Wi-Fi SSID. We then register our custom interrupt handlers to manage TCP-related messages received by the Wi-Fi chip.

When the interrupt signifying a TCP transmission is triggered, data is fed to the interrupt as a packet of bytes (Listing 2). While TCP does guarantee the delivery of data, it does not guarantee how many packets the data will be formatted into when sent. This makes it the developer’s responsibility to ensure that all data is received, even if it is broken into many packets. This problem can be handled by including a header with the message length, but our packet size is always the same (whatever is required by the resolution of the image) and can be agreed upon before the code is flashed to the Pico.

The interrupt handler for TCP packets.

static int dump_bytes(const uint8_t *bptr, uint32_t len){    unsigned int led_i;    unsigned int rot_i;    unsigned char rgb_i;    uint8_t x;    for (unsigned int i = 0; i < len; i++)    {        x = bptr[i];        rgb_i = arr_i % 3;        led_i = (arr_i / 3) % LED_NUM;        rot_i = (arr_i / (LED_NUM * 3)) % ROTATIONS;        led_array[rot_i][led_i][rgb_i] = x;        arr_i++;    }    return rot_i + 1;}

Because the size of an image is known, the Pico continues listening for packets until enough data has been received. It then rebuilds the 3D polar array that represents the image, identical to the array sent from the laptop’s Python client. This array is stored in memory accessible to both cores, making it available to core one’s display logic.

Core one tackles the issue of controlling the LEDs. The core first initializes the relevant GPIO pins for SPI communication, then outputs a test pattern to the LEDs as a visual indicator that initialization was successful. It also registers an interrupt handler for the rising edge of a Hall effect sensor, which is explained below.

Using the image information supplied by core zero, the image is almost ready to be displayed. We still need one more piece of information: the current position of the arm. To display a steady image, we need to know what “slice” of the image is currently being displayed. Instead of trying to measure the position of the arm directly, we use some mathematical trickery to form an estimate. By using a Hall effect sensor to determine when the arm passes a magnet mounted to the base, we get a sub-millisecond measurement of the period of the arm’s rotation, and stable a zero point in the viewer’s frame of reference. Given that the arm is rotating at a constant speed, the amount of time that each “slice” of the image should be displayed is the period divided by the number of pixels per rotation. This is easy to keep track of using an MCU, and we accomplished it using the yield functionality of the Protothreads library [1]. We determined the yield time using the following equation:

yieldtime = (period of rotation/changes per rotations) – LED update time

The Hall effect sensor we chose is active low and pulls a GPIO pin to ground whenever the south pole of a magnet gets close. We set up a falling edge interrupt on the pin, triggering whenever the sensor moves past the stationary magnet on the motor mount. When the interrupt is triggered, the period of rotation is calculated by subtracting the last activation from the current time. We also check that the period is a reasonable value (>10,000µs), which helps us reject any high-frequency false positives. We also indicate that we have hit our zero point by setting the relevant flag.

Finally, we can update the LEDs! We chose APA102 LEDs because they use the high-speed SPI protocol to communicate. A common pitfall of POV display design is attempting to use the ubiquitous WS2812b LEDs (also known as Adafruit NeoPixels). These LEDs use a single wire control protocol and don’t have the required bandwidth for high speed refreshes. The SPI interface has the additional benefit of allowing us to use the Pico’s SPI peripheral to simplify the driver. The LEDs expect packets that are broken into “frames” of 32 bits. Each message begins with a start frame of 32 0’s and ends with an end frame of 32 1’s. In between, each frame represents the data for a single LED in the strip. A LED frame starts with 111, then is followed by 5 bits representing the brightness of the LED. This is followed by 8 bits for each of blue, green, and red, giving 256 values for each.

The LEDs are wired in series, with the SCK and MOSI lines of the previous LED leading into the next. When an LED receives a packet, it updates its state, strips the first LED frame off the packet, and then shifts the new packet out of its output SCK and MOSI lines. By doing so the entire strip can be updated from a single message sent to the first LED.


Various holographic images on the POV display are shown in Figure 10. We can quantify the performance of our display in terms of several metrics:

  1. Resolution: Rotational displays operate slightly differently than traditional grid-based displays. Each “pixel” is actually an arc, and its position is defined in terms of radius and angle rather than x and y. For a POV display, the resolution on the radius is the number of LEDs on the arm, so 40 in our case. The angular resolution depends on how many times the LEDs update per rotation. We experimented and determined that 300 updates produced vivid images without overwhelming our MCU. Multiplying these quantities gives 12,000 pixels, which is much higher than comparable DIY systems.
  2. Size: POV displays become exponentially more complicated as they grow larger. Large radius results in higher acceleration, more LEDs required for equivalent pixel density, and more power required. Many POV projects are under 6” in diameter for this reason. Because our goal was to create a visually impressive product, we decided to aim for around the size of a large poster. This resulted in a 26” diameter display. This posed many technical challenges, but the result is absolutely stunning.
  3. Image Stability: Due to the high speed of the system and the noisy signals generated by the Hall effect sensor, it can be difficult to determine the exact rotational frequency. This can cause the image to jitter or process around the display. Our display dealt with these issues remarkably, with almost no visible jitter. Any noise was constrained to within one angular pixel, or under 2 degrees.
  4. Usability: Our design emphasizes usability and consistency. Good build quality ensures that repeated use doesn’t cause incremental damage, decreasing the life span. The system is powered by a single wall outlet, so no specialized hardware is required. It is resilient to fluctuations in motor speed, so replacement or modification is easy. The custom Python interface allows for use of the display with any image you choose, and updates can be sent over Wi-Fi without slowing down the display.

All the code and design files for this project are available on GitHub [2]. See Circuit Cellar’s Article Materials and Resources webpage.

Six different holographic images shown on the display.
Six different holographic images shown on the display.

One notable issue with our display is the spacing between the pixels. Because of the physical requirements for soldering the arm, there are small gaps between the LEDs. This causes circular interruptions in the image, and could be remedied by using two staggered rows of LEDs.

We also hope to improve the interface for transmitting images and video. It would be interesting to write a display driver that allows the display to mirror a computer screen. This would make the display interface even more intuitive.


Special thanks to Cornell’s Professor Hunter Adams and Professor Bruce Land for all of their help.

[1] Adam Dunkels, “Protothreads.”:
[2] Code and design files for the project at GitHub:

Raspberry Pi |


Advertise Here

Code and Supporting Files


Keep up-to-date with our FREE Weekly Newsletter!

Don't miss out on upcoming issues of Circuit Cellar.

Note: We’ve made the Dec 2022 issue of Circuit Cellar available as a free sample issue. In it, you’ll find a rich variety of the kinds of articles and information that exemplify a typical issue of the current magazine.

Would you like to write for Circuit Cellar? We are always accepting articles/posts from the technical community. Get in touch with us and let's discuss your ideas.

Sponsor this Article
+ posts

Michael Crum ( is an undergraduate Junior studying Computer Science at Cornell University. He is primarily interested in embedded systems and their applications in robotics. See more of his work at

Joseph Horwitz ( is an undergraduate Senior in Electrical and Computer Engineering at Cornell University. He is also interested in embedded systems and firmware development.

Rabail Makhdoom( is a Master of Engineering (M.Eng) student in Electrical and Computer Engineering at Cornell University. She is primarily interested in analog IC design, power electronics, and robotics.

Supporting Companies

Upcoming Events

Copyright © KCK Media Corp.
All Rights Reserved

Copyright © 2024 KCK Media Corp.

Building a Holographic Persistence-of-Vision Display

by Michael Crum, Joseph Horwitz, and Rabail Makhdoom time to read: 15 min