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 once. zip()
(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:
- Load line data from adafruit_logo_vector.py
- Apply offset correction to centre the image.
- Interpolate lines using
addpoints()
function to make them appear solid. - Loop:
- Rotate image data and write DAC output for frame to an
array.array
. - Output data to DAC with
dacs.play()
(see excerpt below). - Pause for frame length.
- Rotate image data and write DAC output for frame to an
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.
- Select bitmap image which can be represented well with line art.
- Load bitmap into Inkscape.
- Vectorise - inspect and adjust result as necessary.
- Flatten - this will convert any (bezier) curves into a series of straight lines.
- Save as an svg file.
- 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:])
Page last edited January 22, 2025
Text editor powered by tinymce.