Skip to main content

Jason Slade

IIoT Director | SCADA | MQTT | Controls Engineering

One Beam, 1200 Points: Drawing What a Camera Sees With a Laser

A laser projector has one beam. Whatever you want it to draw, it draws by moving that single point of light around fast enough that your eye gives up and calls it a shape. There is no frame buffer, no pixels, nothing drawn in parallel. Two mirrors on galvanometers steer the beam, and everything you see is the path they traced in the last fortieth of a second.

That constraint is the entire project. I wanted to point a camera at something and have a laser draw it — which sounds like a rendering problem and turns out to be a routing problem.

The easy part

The computer vision is the part people assume is hard, and it is the part that took an evening. OpenCV finds edges with Canny, findContours turns the edge image into lists of boundary points, and Douglas–Peucker throws away the points that were not saying anything. Four extraction modes, because “what should the laser trace?” has genuinely different answers depending on what you are pointing it at:

  • Canny edges — busy and detailed, the right general answer.
  • Adaptive threshold — clean silhouettes, much better on a backlit subject.
  • Background subtraction — only what moves gets drawn. This is the one that makes the demo. Stand in front of the camera and the laser outlines you, and nothing else in the room.
  • An HSV colour key — hold up something in the keyed colour and only that is traced.

At the end of that stage you have a few dozen polylines and a warm feeling that you are nearly finished. You are not nearly finished.

Where a laser stops behaving like a screen

Hand those contours straight to a DAC and you get something recognisable but wrong. It flickers. The lines are bright in some places and washed out in others. Corners overshoot into little hooks. Faint ghost lines connect shapes that should not be connected. Every one of those is a physical fact about mirrors with mass, and every one needs handling in software.

Brightness is dwell time. The beam deposits light in proportion to how long it lingers, so if your points are unevenly spaced along a path, the line is bright where they bunch up and dim where they spread out. The fix is to throw away the original sampling entirely and resample along arc length: emit a point every sixty DAC units of distance travelled, regardless of where the contour’s vertices happened to fall. At 30,000 points per second that works out to a beam speed of about 1.8 million units per second, which is a number I now have opinions about.

Corners need warning. Ask a galvanometer to turn ninety degrees at full speed and it arrives late and overshoots. The trick is old and simple: repeat the corner point. I scale it to the turn — a vertex turning through θ degrees gets round(θ / 30) extra points, capped at eight — so a gentle bend costs nothing and a hard corner buys the mirrors the time they need.

Travel between shapes is wasted time, and it is not free. Getting from the end of one contour to the start of the next means moving the beam across the field with the laser off. That still costs points, and points are the budget. Ordering the contours with a greedy nearest-neighbour walk — letting open paths run in either direction, and rotating closed contours so they start at whichever vertex is nearest — cuts the blanked travel substantially over the order findContours happens to return. Frames start where the previous frame ended, too, so the beam is not flung back to the middle between refreshes.

The travel move itself has to be interpolated. This one I got wrong first. Blanking the beam and jumping straight to the next coordinate looks like it should be fine, because nothing is lit. But the mirrors still physically slew the whole distance, and if you have not told them how, they arrive ringing — and that ringing shows up as a wobble in the first few millimetres of the next lit line. So blanked travel gets interpolated too, just more coarsely.

And the beam has to settle before it lights up. Eight blanked points parked on a path’s first vertex, then the beam comes on. Six more on the way out before it travels away. That single detail is the difference between a clean line start and a smear, and it is invisible until you look at a projection and wonder why every shape has a comet tail.

The budget

Here is the part that reframes the whole thing. The Helios DAC holds a maximum of 4096 points per frame. But the real limit is tighter and comes from arithmetic:

budget = min(4096, pps / fps)

A frame of n points at p points per second takes n/p seconds to draw. If you want 25 frames a second at 30,000 points per second, your entire scene — every contour, every corner dwell, every blanked travel move — has to fit in 1200 points. Go over and the refresh rate drops and the image flickers.

So the optimiser has to be willing to throw things away. It keeps contours longest-first, so a busy frame degrades by losing fine detail rather than by losing whichever shape sorted last. If a single contour still will not fit, it coarsens the sample step instead of dropping it, because a slightly chunkier version of the one thing in the scene beats nothing. In practice a typical camera frame comes out around 700 points for a dozen contours, which leaves useful headroom.

Talking to the hardware

The Helios is a lovely little device: USB 1209:E500, 12-bit X and Y, 8-bit RGB, and an MIT-licensed SDK. I drive it two ways. The primary path is ctypes against the vendor’s own DLL, which ships vendored in the repo so a fresh clone can drive real hardware with nothing but pip install. The secondary is a clean-room pyusb backend for Linux and macOS, written against the published protocol.

Writing that second one taught me something I would not otherwise know. A frame goes out as seven bytes per point plus a five-byte trailer, and the MCU mishandles any bulk transfer whose length is an exact multiple of the 64-byte USB packet size. So (7n + 5) mod 64 == 0 is a bad frame — which is n mod 64 == 45. The vendor SDK quietly drops the last point when that happens. My backend does the same, and rescales the scan rate by (n-1)/n so the frame still takes the same wall-clock time and nothing visibly changes. There is a unit test that asserts exactly this, because it is the kind of thing that would otherwise be rediscovered painfully at two in the morning.

There is also a third backend that is not hardware at all: a simulator that enforces the identical limits and models frame timing, so GetStatus goes false for n/pps seconds just like the real thing. Everything above the driver line is testable without a DAC, and the whole test suite — 95 tests covering the ILDA parser, the geometry, the optimiser invariants, the budget, the safety validators and the wire encoding — runs on a laptop with nothing plugged in.

Reading other people’s ILDA files

ILDA is the interchange format for laser frames, and I wrote a reader for it so the project could play known-good content while calibrating. The spec is clear. Real files are not.

The test file I built against has non-zero junk in the header bytes the specification reserves, and it ends exactly at the last record with none of the end-of-file trailer the spec describes. Validate the reserved bytes and you reject a perfectly good file; require the trailer and you throw on EOF. Both behaviours are now assertions in the test suite, on the principle that the bug you have already had is the one worth pinning down.

One more trap: formats 4 and 5 store colour as blue, green, red, in that order. Get it backwards and everything still parses, still projects, and is quietly wrong.

The part I am obliged to take seriously

I hold a Certified Laser Safety Officer credential, which mostly means I have had it explained to me at length exactly how many ways this can go badly. So the safety layer here is enforced in code, not written in the README and hoped for.

Every frame — whatever produced it, camera or test pattern or ILDA file — passes through a validator immediately before it is written. It rejects a frame where a lit point repeats more than twelve times, because a lit beam that stops moving is the difference between a light show and a cutting tool. It rejects a frame whose lit content has collapsed below two percent of the scan field, because a picture that has degenerated to a dot delivers its entire power into one spot. It applies the brightness ceiling at that boundary rather than upstream, so no code path can raise it. A watchdog blanks the output if the pipeline stops producing frames for two seconds — a dropped stream, an exception, a wedged camera. And every exit path there is, including an unhandled exception and interpreter teardown, converges on the same shutdown that writes a blank frame and closes the shutter.

That last one matters more than it looks, because the DAC *loops the last frame it was given* until something replaces it. A host crash leaves the projector cheerfully running that frame forever. Which is why the validators exist at all: not merely to make the current output safe, but to guarantee that any frame capable of being left looping is a moving, power-capped pattern.

And then the honest part, which is in the README in as many words:

This is not a scan-fail safeguard. A software watchdog cannot detect a seized galvanometer. If a mirror stops moving, the DAC carries on reporting ready and accepting frames while the beam sits still at full power. Only the projector’s own scan-fail circuit and the ILDA interlock loop can catch that. This program is a contributing control on the software side. The safety system is hardware.

I would rather ship that sentence than a claim I cannot back.

Where it is

The write path is verified against the real DAC — firmware 5, twenty frames of six hundred points sustained at 52 fps, using blanked frames so there was no optical output while I was testing. The pipeline runs end to end. What is left is first light on the projector itself: a square test pattern at twenty percent power, the calibration dance of flip, rotate and scale until the geometry lands where I want it, and then a camera.

Code is on GitHub: trih3lix/Webcam-ILDA-Laser-DAC-control. MIT, 95 tests, and a --dry-run mode so you can watch the whole thing work before you own a laser.

← All posts

→ Subscribe by RSS