hacks_pygamer-2dac-xy-lissajous-3vs1ph-2000x1500.jpg
Classic lissajous figure using x-y oscilloscope inputs.

Simple Figures

Lissajous figures are classic x-y oscilloscope imagery. They are easily created from sine waves. The short code below shows how to create the one pictured above, the same one that inspired the Australian Broadcasting Corporation (ABC) logo.

# Lissajous version 1
import array, math
import board, audioio, audiocore
 
length = 1000
samples_xy = array.array("H", [0] * length * 2)

# Created interleaved x, y samples
for idx in range(length):
    samples_xy[2 * idx] = round(math.sin(math.pi * 2 * idx / length) * 10000 + 10000)
    samples_xy[2 * idx + 1] = round(math.sin(math.pi * 2 * 3 * idx / length + math.pi / 2) * 10000 + 10000)

output_wave = audiocore.RawSample(samples_xy,
                                channel_count=2,
                                sample_rate=100*1000)
dacs.play(output_wave, loop=True)
while True:
   pass

This image, based on 1000 samples per channel, is flicker-free at 100 kHz output rate. There's some minor flicker at (audio) rates like 48 kHz.

The samples can also be output with the analogio library by writing a loop to assign to the two DACs. The version 2 code below shows a typical approach.

# Lissajous version 2
import array, math
import board, analogio
length = 1000
samples_x = array.array("H", [0] * length)
samples_y = array.array("H", [0] * length)

for idx in range(length):
    samples_x[idx] = round(math.sin(math.pi * 2 * idx / length) * 10000 + 10000)
    samples_y[idx] = round(math.sin(math.pi * 2 * 3 * idx / length + math.pi / 2) * 10000 + 10000)

dac_a0 = analogio.AnalogOut(board.A0)
dac_a1 = analogio.AnalogOut(board.A1)
    
while True:
    for x, y in zip(samples_x, samples_y):
        dac_a0.value = x
        dac_a1.value = y

Version 2 flickers a little at high brightness showing the performance of the interpreter is just about adequate for looping over a thousand sample pairs. However, it has very visible bright spots appearing every second or so. These artefacts in some ways look attractive but they are not intentional and it's useful to understand why they occur.

There will be a small gap in time as the for loop finishes and the while loop executes the next for loop. The bright spots move around the figure so this cannot be an explanation for the brief pause in beam movement. The "random" placement of the bright spot suggests something else is causing a pause in the execution of the application code leaving the beam stationary for a moment. This is probably some regular tidying of memory by the CircuitPython interpreter known as a garbage collection (GC).

# snippet of Lissajous version 3

while True:
    for idx in range(length):
        a0.value = samples_x[idx]
        a1.value = samples_y[idx]

Version 3 replaces the for loop with one which just iterates over the array indices rather than using the zip(). The bright spots have gone with this simpler code. This makes sense as this code has no need to allocate and free memory as it loops.

Another approach would be to move zip() outside the loop and only create the object once to make for loop more efficient. This well-intentioned migration will not work as it only display the figure oncezip() (in CircuitPython based on Python 3) returns an iterator which is designed to be used once. If the approach of constructing the two lists is maintained then zip() can be used to make a single list with the aid of list(). Version 4 shows this with an additional performance enhancement of removing the temporary variables in the loop and assigning to them directly.

# snippet of Lissajous version 4

samples_both = list(zip(samples_x, samples_y))
while True:
    for a0.value, a1.value in samples_both:
        pass

Spinning Adafruit Logo

Download the code.py file with the link below and adafruit_logo_vector.py file. Plug your PyGamer or other M4-based board into your computer via a known-good USB data cable. A flash drive named CIRCUITPY should appear in your file explorer/finder program. Copy code.py and adafruit_logo_vector.py to the CIRCUITPY drive.

Connect the PyGamer A0 output to the x oscilloscope input, A1 to the y input and GND to ground to see the image. Three short jumper cables will facilitate connection to a Feather female header.

Scroll past the code below for a video showing the image output.

# SPDX-FileCopyrightText: 2019 Kevin J. Walters for Adafruit Industries
#
# SPDX-License-Identifier: MIT

### scope-xy-adafruitlogo v1.0

"""Output a logo to an oscilloscope in X-Y mode on an Adafruit M4
board like Feather M4 or PyGamer (best to disconnect headphones).
"""

### copy this file to PyGamer (or other M4 board) as code.py

### MIT License

### Copyright (c) 2019 Kevin J. Walters

### Permission is hereby granted, free of charge, to any person obtaining a copy
### of this software and associated documentation files (the "Software"), to deal
### in the Software without restriction, including without limitation the rights
### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
### copies of the Software, and to permit persons to whom the Software is
### furnished to do so, subject to the following conditions:

### The above copyright notice and this permission notice shall be included in all
### copies or substantial portions of the Software.

### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
### SOFTWARE.

import time
import math
import array

import board
import audioio
import audiocore
import analogio

### Vector data for logo
import adafruit_logo_vector

VECTOR_POINT_SPACING = 3

def addpoints(points, min_dist):
    """Add extra points to any lines if length is greater than min_dist"""

    newpoints = []
    original_len = len(points)
    for pidx in range(original_len):
        px1, py1 = points[pidx]
        px2, py2 = points[(pidx + 1) % original_len]

        ### Always keep the original point
        newpoints.append((px1, py1))

        diff_x = px2 - px1
        diff_y = py2 - py1
        dist = math.sqrt(diff_x ** 2 + diff_y ** 2)
        if dist > min_dist:
            ### Calculate extra intermediate points plus one
            extrasp1 = int(dist // min_dist) + 1
            for extra_idx in range(1, extrasp1):
                ratio = extra_idx / extrasp1
                newpoints.append((px1 + diff_x * ratio,
                                  py1 + diff_y * ratio))
        ### Two points define a straight line
        ### so no need to connect final point back to first
        if original_len == 2:
            break

    return newpoints

### pylint: disable=invalid-name
### If logo is off centre then correct it here
if adafruit_logo_vector.offset_x != 0 or adafruit_logo_vector.offset_y != 0:
    data = []
    for part in adafruit_logo_vector.data:
        newpart = []
        for point in part:
            newpart.append((point[0] - adafruit_logo_vector.offset_x,
                            point[1] - adafruit_logo_vector.offset_y))
        data.append(newpart)
else:
    data = adafruit_logo_vector.data


### Add intermediate points to make line segments for each part
### look like continuous lines on x-y oscilloscope output
display_data = []
for part in data:
    display_data.extend(addpoints(part, VECTOR_POINT_SPACING))

### PyPortal DACs seem to stop around 53000 and there's 2 100 ohm resistors
### on output so maybe large values aren't good idea?
### 32768 and 32000 exhibit this bug but 25000 so far appears to be a
### workaround, albeit a mysterious one
### https://github.com/adafruit/circuitpython/issues/1992
### Using "h" for audiocore.RawSample() DAC range will be 20268 to 45268
dac_x_min = 0
dac_y_min = 0
dac_x_max = 25000
dac_y_max = 25000
dac_x_mid = dac_x_max // 2
dac_y_mid = dac_y_max // 2

### Convert the points into format suitable for audio library
### and scale to the DAC range used by the library
### Intentionally using "h" data representation here as this happens to
### cause the CircuitPython audio libraries to make a copy of
### rawdata which is useful to allow animating code to modify rawdata
### without affecting current DAC output
rawdata = array.array("h", (2 * len(display_data)) * [0])

range_x = 512.0
range_y = 512.0
halfrange_x = range_x / 2
halfrange_y = range_y / 2
mid_x = 256.0
mid_y = 256.0
mult_x = dac_x_max / range_x
mult_y = dac_y_max / range_y

### https://github.com/adafruit/circuitpython/issues/1992
print("length of rawdata", len(rawdata))

use_wav = True
poor_wav_bug_workaround = False
leave_wav_looping = True

### A0 will be x, A1 will be y
if use_wav:
    print("Using audiocore.RawSample for DACs")
    dacs = audioio.AudioOut(board.A0, right_channel=board.A1)
else:
    print("Using analogio.AnalogOut for DACs")
    a0 = analogio.AnalogOut(board.A0)
    a1 = analogio.AnalogOut(board.A1)

### 10Hz is about ok for AudioOut, optimistic for AnalogOut
frame_t = 1/10
prev_t = time.monotonic()
angle = 0  ### in radians
frame = 1
while True:
    ##print("Transforming data for frame:", frame, "at", prev_t)

    ### Rotate the points of the vector graphic around its centre
    idx = 0
    sine = math.sin(angle)
    cosine = math.cos(angle)
    for px, py in display_data:
        pcx = px - mid_x
        pcy = py - mid_y
        dac_a0_x = round((-sine * pcx + cosine * pcy + halfrange_x) * mult_x)
        ### Keep x position within legal values (if needed)
        ##dac_a0_x = min(dac_a0_x, dac_x_max)
        ##dac_a0_x = max(dac_a0_x, 0)
        dac_a1_y = round((sine * pcy + cosine * pcx + halfrange_y) * mult_y)
        ### Keep y position within legal values (if needed)
        ##dac_a1_y = min(dac_a1_y, dac_y_max)
        ##dac_a1_y = max(dac_a1_y, 0)
        rawdata[idx] = dac_a0_x - dac_x_mid      ### adjust for "h" array
        rawdata[idx + 1] = dac_a1_y - dac_y_mid  ### adjust for "h" array
        idx += 2

    if use_wav:
        ### 200k (maybe 166.667k) seems to be practical limit
        ### 1M permissible but seems same as around 200k
        output_wave = audiocore.RawSample(rawdata,
                                        channel_count=2,
                                        sample_rate=200 * 1000)

        ### The image may "warp" sometimes with loop=True due to a strange bug
        ### https://github.com/adafruit/circuitpython/issues/1992
        if poor_wav_bug_workaround:
            while True:
                dacs.play(output_wave)
                if time.monotonic() - prev_t >= frame_t:
                    break
        else:
            dacs.play(output_wave, loop=True)
            while time.monotonic() - prev_t < frame_t:
                pass
            if not leave_wav_looping:
                dacs.stop()
    else:
        while True:
            ### This gives a very flickery image with 4932 points
            ### slight flicker at 2552
            ### might be ok for 1000
            for idx in range(0, len(rawdata), 2):
                a0.value = rawdata[idx]
                a1.value = rawdata[idx + 1]
            if time.monotonic() - prev_t >= frame_t:
                break
    prev_t = time.monotonic()
    angle += math.pi / 180 * 3 ### 72 degrees per frame
    frame += 1

Oscilloscope Output Video

The video below shows the spinning logo output from a PyGamer connected to a Hameg HM203-6 oscilloscope in x-y mode. This more complex code uses the audioio libraries for DAC output but still features bright spots. This is probably due to the changeover between one frame's data to the next, garbage collection should not be a factor as it would not interrupt the DMA transfers. There are also some faint spots visible some of the time. These might be related to some unexplained issues with stepping, rising slew rate on SAMD51 DACs.

Code Discussion

The essence of the code is:

  1. Load line data from adafruit_logo_vector.py
  2. Apply offset correction to centre the image.
  3. Interpolate lines using addpoints() function to make them appear solid.
  4. Loop:
    1. Rotate image data and write DAC output for frame to an array.array.
    2. Output data to DAC with dacs.play()(see excerpt below).
    3. Pause for frame length.

The array.array type is carefully chosen as "h" for signed integers. For DAC output with audiocore.RawSample(), the library happens to make a copy of the data for output rather than using it in-place. This is useful to allow the loop to efficiently reuse the same array.array without the risk of mixing two different frames in the (looping) DAC output.

The play() method is invoked with loop=True which leaves the frame being continuously sent to the oscilloscope until either a stop() or the next play() is executed. This is very useful for keeping the the image on the oscilloscope and avoiding any long periods where the beam is stationary.

dacs.play(output_wave, loop=True)
while time.monotonic() - prev_t < frame_t:
    pass
if not leave_wav_looping:
    dacs.stop()

When the while loop terminates as time passes beyond the duration frame_t the code will go on to calculate the next frame (not shown) whilst continuing to send output to the DACs. If leave_wav_looping is set to False then DAC output will cease and there will be both considerable flicker between frames and a bright spot.

A sophisticated garbage collection system is typically better than the programmer at scheduling concurrent vs blocking (stop the world) collections. For this particular program, there are some opportune points to execute gc.collect() to avoid less opportune scheduling. This is an area to explore.

Making Vector Images

If an image is only available in bitmap form then it will need converting to vector form for display. Inkscape is one, free, multi-platform application which can do this.

  1. Select bitmap image which can be represented well with line art.
  2. Load bitmap into Inkscape.
  3. Vectorise - inspect and adjust result as necessary.
  4. Flatten - this will convert any (bezier) curves into a series of straight lines.
  5. Save as an svg file.
  6. Extract line data from the svg file - the svgtopy utility below can help with this.

The example command line and code below can read simple svg files and print them as lists suitable for inclusion in a CircuitPython program.

svgtopy < logo-flattened.svg
# SPDX-FileCopyrightText: 2019 Kevin J. Walters for Adafruit Industries
#
# SPDX-License-Identifier: MIT

#!/usr/bin/python3

### svgtopy v1.0
"""Print vectors from an SVG input file in python list format
for easy pasting into a program.

This is Python code not intended for running on a microcontroller board.
"""

### MIT License

### Copyright (c) 2019 Kevin J. Walters

### Permission is hereby granted, free of charge, to any person obtaining a copy
### of this software and associated documentation files (the "Software"), to deal
### in the Software without restriction, including without limitation the rights
### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
### copies of the Software, and to permit persons to whom the Software is
### furnished to do so, subject to the following conditions:

### The above copyright notice and this permission notice shall be included in all
### copies or substantial portions of the Software.

### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
### SOFTWARE.


### it only understands M and L in SVG

### Worth looking at SVG libraries to see if they
### can parse/transform SVG data

import getopt
import sys
import re
##import fileinput
import xml.etree.ElementTree as ET

### globals
### pylint: disable=invalid-name
debug = 0
verbose = False


def usage(exit_code):  ### pylint: disable=missing-docstring
    print("""Usage: svgtopy [-d] [-h] [-v] [--help]
Convert an svg file from from standard input to comma-separated tuples
on standard output for inclusion as a list in a python program.""",
          file=sys.stderr)
    if exit_code is not None:
        sys.exit(exit_code)


def search_path_d(svgdata, point_groups):
    """Look for M and L in the SVG d attribute of a path node"""

    points = []
    for match in re.finditer(r"([A-Za-z])([\d\.]+)\s+([\d\.]+)\s*", svgdata):
        if match:
            cmd = match.group(1)
            if cmd == "M":  ### Start of a new part
                mx, my = match.group(2, 3)
                if points:
                    point_groups.append(points)
                    points = []
                points.append((float(mx), float(my)))
                if debug:
                    print("M pos", mx, my)

            elif cmd == "L":  ### Continuation of current part
                lx, ly = match.group(2, 3)
                points.append((float(lx), float(ly)))
                if debug:
                    print("L pos", lx, ly)

            else:
                print("SVG cmd not implemented:", cmd,
                      file=sys.stderr)
        else:
            print("some parsing issue",
                  file=sys.stderr)

    # Add the last part to point_groups
    if points:
        point_groups.append(points)
        points = []


def main(cmdlineargs):
    """main(args)"""
    global debug, verbose  ### pylint: disable=global-statement

    try:
        opts, _ = getopt.getopt(cmdlineargs,
                                "dhv", ["help"])
    except getopt.GetoptError as err:
        print(err,
              file=sys.stderr)
        usage(2)
    for opt, _ in opts:
        if opt == "-d":
            debug = True
        elif opt == "-v":
            verbose = True
        elif opt in ("-h", "--help"):
            usage(0)
        else:
            print("Internal error: unhandled option",
                  file=sys.stderr)
            sys.exit(3)

    xml_ns = {"svg": "http://www.w3.org/2000/svg"}
    tree = ET.parse(sys.stdin)
    point_groups = []
    for path in tree.findall("svg:path", xml_ns):
        svgdata = path.attrib["d"]
        if verbose:
            print("Processing path with {0:d} length".format(len(svgdata)))
        search_path_d(svgdata, point_groups)



    for idx, points in enumerate(point_groups):
        print("# Group", idx + 1)
        for point in points:
            print("     ", point, ",", sep="")

if __name__ == "__main__":
    main(sys.argv[1:])

This guide was first published on Jul 22, 2019. It was last updated on Jun 25, 2019.

This page (Two DACs) was last updated on Mar 08, 2023.

Text editor powered by tinymce.