← Home

The Lissajous Sweep

Before I trusted my tracker to point the camera at real aircraft, I needed to know the gimbal actually moved the way I thought it did. The arc-second unit conversion, the tilt-before-pan byte order, the clamping logic - any of it could be subtly wrong in a way that’s hard to spot when you’re also trying to chase a moving target.

So I wrote a test: a script that sweeps the camera continuously in a pattern I can watch and reason about. I wanted coverage of the full range, smooth movement, and something visually interesting enough that I’d notice if it went wrong.

3:7 Lissajous sweep

That pattern is a Lissajous figure.

What a Lissajous Figure Is

A Lissajous figure is what you get when you drive two axes with independent sine waves at different frequencies. The classic physics demo is an oscilloscope with sine waves on X and Y. The shape you get depends on the ratio of the frequencies and the phase between them.

For the camera: pan follows one sine wave, tilt follows another. Both start at zero phase. If the periods were the same, the camera would trace a straight diagonal line forever - not useful coverage. With different periods, the two waves drift in and out of phase and the path slowly rotates and fills in.

pan  = PAN_ARC  * math.sin(2 * math.pi * t / PAN_PERIOD)
tilt = tilt0 + TILT_ARC * math.sin(2 * math.pi * t / TILT_PERIOD)

PAN_PERIOD is 30 seconds. TILT_PERIOD is 70 seconds. The ratio 30/70 = 3/7 is not a simple fraction - which means the pattern never exactly repeats. The camera keeps covering new ground rather than retracing the same loop.

If you chose a ratio of exactly 1:2 or 2:3, you’d get a closed figure-8 or a pretzel shape, and the camera would keep redrawing the same path indefinitely. Those are pretty but they’re bad for coverage testing. An irrational or awkward ratio keeps the sweep exploratory.

The Terminal Display

While it runs I wanted to see what the camera is actually doing without switching to the video feed. So the script draws a little ASCII art grid in the terminal, updated in place:

Live terminal display (same coordinate mapping as the script)

The is the current camera position. The grid has axis lines through the zero point so you can see where centre is. Each frame, it moves the cursor up by the grid height and overwrites the previous frame in-place, so there’s no scrolling:

up = len(lines)
print(f"\033[{up}A" + "\n".join(lines), flush=True)

\033[{up}A is an ANSI escape sequence that moves the cursor up by up lines. Then the script prints the new frame on top. Simple animation without the curses module.

The grid is 50 columns wide and 9 rows tall. I picked those numbers to fit comfortably in a standard terminal without being so large it takes over the screen. Each column is about 6.8° of pan, each row about 4.4° of tilt - fine resolution for confirming the gimbal is moving in the right direction and reaching the right extremes.

The Timing Loop

At 5 Hz, each iteration has a 200ms budget. The USB round-trip to send a pan/tilt command takes about 20ms. I want to sleep for whatever is left rather than sleeping a fixed 200ms and letting the USB overhead accumulate:

interval = 1.0 / UPDATE_HZ
t0 = time.monotonic()

while not stopped:
    t = time.monotonic() - t0
    pan  = PAN_ARC  * math.sin(2 * math.pi * t / PAN_PERIOD)
    tilt = tilt0 + TILT_ARC * math.sin(2 * math.pi * t / TILT_PERIOD)

    cam.set_pan_tilt_degrees(pan, tilt)
    draw(pan, tilt, ...)

    elapsed = time.monotonic() - t0 - t
    remaining = interval - elapsed
    if remaining > 0:
        time.sleep(remaining)

t is measured at the top of the loop before the USB call. elapsed is how long the loop body actually took. remaining is the budget minus what was spent. If a USB call takes longer than usual - which happens occasionally when the kernel UVC driver reclaims the device mid-cycle - the next iteration starts immediately rather than falling behind.

This also means the position computation uses a consistent t relative to t0, not a timestamp taken after the USB call. The sine waves stay phase-coherent regardless of jitter in the command round-trip.

Ctrl+C Handling

The useful thing about a sweep that covers the full gimbal range is that stopping it abruptly leaves the camera pointing somewhere arbitrary. I wanted Ctrl+C to return the camera to wherever it started:

def stop_and_restore() -> None:
    nonlocal stopped
    stopped = True
    cam.set_pan_tilt_degrees(pan0, tilt0)
    time.sleep(3)

signal.signal(signal.SIGINT, handle_sigint)

pan0 and tilt0 are read from the camera at startup with get_pan_tilt_degrees(). On Ctrl+C, the script sends the camera back to that position and waits 3 seconds for the gimbal to physically get there before printing the final confirmed position and exiting.

The time.sleep(3) is a guess. The gimbal takes roughly 2–3 seconds to traverse its full range at default speed. Waiting 3 seconds is usually enough. An alternative would be to poll get_pan_tilt_degrees() until it’s close enough to the target, but the sleep is simpler and has worked every time I’ve used it.

What I Learned From It

The sweep caught two bugs before I started on the tracker:

First, pan and tilt were swapped in the SET payload. Sending pan=30, tilt=10 was physically producing tilt=30, pan=10. This is the tilt-before-pan byte order documented in my reverse engineering post - I’d read it correctly from the USB captures but got confused putting it back together. Watching the ASCII grid move sideways when I expected up/down made it obvious immediately.

Second, the degree-to-arc-second conversion had a sign error on tilt. Positive tilt should move the camera up; I had it going down. Again, immediately obvious on the grid, not at all obvious from reading the code.

Neither of these would have been easy to catch by pointing at a specific aircraft, because any error there could look like a coordinate geometry mistake rather than a control layer bug. The sweep isolates the gimbal control from everything else and gives you something simple to look at.


Get notified when I publish new writeups and progress updates.

← Back