Projects

Portrait Drawing Robot

Look at the camera and a pen plotter draws you — a webcam frame turned into ordered strokes a machine can follow.

Computer vision · path planning Python, OpenCV, G-code 2024
The finished line drawing: a portrait reduced to thin black contour strokes on white, showing a face with glasses
4edge operators compared
11sampling radii, 0 to 10
0.5 mmof paper per pixel
150.5 mmsquare drawing

A plotter cannot be handed a photograph. It follows a list of moves: go here with the pen up, go there with the pen down. So the whole project is one long reduction — from a 24-bit webcam frame, to a grayscale face, to a one-pixel-wide edge map, to a graph of strokes, to G-code.

The first half is ordinary computer vision and the notebook works through it operator by operator, comparing the options rather than assuming one. The second half is the interesting half: an edge image is a set of lit pixels with no notion of a line, a corner or a branch, and the plotter needs all three.

Finding the face.

Crop to what matters, then fix the lighting.

Detection

A Haar cascade, then a generous crop.

A photo from a laptop webcam is mostly room. The classic Viola–Jones Haar cascade that ships with OpenCV finds the face — scaleFactor 1.2, minNeighbors 5 — and the box it returns is then grown by 1.3× about its own centre before cropping.

That enlargement matters more than it looks. A tight detection box cuts the hairline, the chin and the ears, and those outlines are most of what makes a line drawing recognisable as a particular person.

A grayscale webcam photo with a square detection box drawn around the subject's face
The detection box, before it is enlarged and cropped.
Contrast

CLAHE beats a global stretch.

Edge detection is only as good as the local contrast it is given, and a face lit from one side has plenty of tone in the bright half and almost none in the shadow. Stretching the histogram globally fixes the average and leaves the shadow flat.

CLAHE — contrast-limited adaptive histogram equalisation — instead equalises each tile of an 8×8 grid on its own, clipping any histogram bin that exceeds a clipLimit of 5 and redistributing the excess so a tile of near-uniform skin does not get amplified into noise. The notebook plots the histogram and CDF for both and keeps CLAHE.

Globally equalised face with its histogram and cumulative distribution plotted below CLAHE-processed face with its histogram and cumulative distribution plotted below
Global equalisation, and CLAHE. Same face, very different shadow detail.

Finding the edges.

Four operators, one of which survives contact with a pen.

Before any gradient is taken the image gets a Gaussian blur, ksize 15 at σ = 2, built the explicit way as a 1-D kernel and its transpose. Derivatives amplify noise, and skin texture is noise as far as an edge detector is concerned.

Then the comparison. Laplacian with a four-neighbour kernel is a second derivative and picks up every speck. Prewitt and Sobel return gradient magnitude, so an edge comes out as a wide ridge that fades at the sides — a plotter would draw it as a thick smear. Canny is the only one of the four that does non-maximum suppression and hysteresis, and so the only one that returns something already one pixel wide.

Four-panel comparison of Laplacian, Prewitt, Sobel and Canny edge detection on the same face
The same blurred frame through all four operators. Only Canny gives thin lines.
Thresholding

Each operator gets its own cut.

Gradient magnitudes are not comparable across operators, so each gets its histogram plotted and its own inverse binary threshold — 3 for the Laplacian, 2 for Canny, 20 for Sobel, 60 for Prewitt. Canny's output goes forward, through one more gradient pass and a final cut at 35, and comes out as a boolean array: true where there is a line, false everywhere else.

The final binary edge map of the face: white contour lines on black
The binary edge map. This is everything the vectoriser gets to work with.

From pixels to strokes.

Growing a ring until it tells you where the line goes.

Here is the actual problem. That boolean array says this pixel is lit. It does not say which pixels belong to the same line, which way the line runs, or where one line forks into two. A pen needs all of that.

The trick is to stop thinking in pixels and start thinking in rings. Around a given edge pixel, sample a discrete circle of radius r — the integer coordinates of every such circle for r = 0…10 are precomputed once in constants.py, in order, so walking a ring is just walking a list. Read the edge map at each ring position and you get a loop of true/false.

Run-length encode that loop, wrapping across the seam, and you get arcs. Now the count means something: two arcs is a line passing through, and four or more arcs is a junction. The midpoint of each true arc is the direction a stroke continues in. A T-junction announces itself as three separate true arcs, with no special case for corners, forks or crossings anywhere in the code.

Diagram of a ring of radius 3 sampled around an edge pixel at a T-junction, with the three edge arcs highlighted, and the same ring unrolled into a strip showing six arcs
A real junction from this image. Six arcs around the ring, three of them lit, so three branches.
Choosing a radius

Grow until the answer stops changing.

One radius does not suit every place in the picture. Too small and a ring sits entirely inside a thick blob; too large and it reaches across a gap into a different line. So the radius grows from zero and the arc count is watched:

  • Keep the radius that produced the most arcs so far.
  • Stop once there are at least four arcs and two consecutive rings do not improve on it — the junction is resolved.
  • Stop early if only two arcs remain and the lit one is under a quarter of the circumference, which means a thin line running straight through.
  • Stop if a ring comes back entirely dark — an isolated speck with nothing to connect to.

With a radius chosen, a depth-first search claims every connected lit pixel within it for the current node, links up to any neighbouring node already claimed, and then recurses outward along each branch. Pixels get consumed exactly once, and what falls out is a graph: nodes on the skeleton of the drawing, edges where the pen should travel between them.

Ordering the pen.

A graph does not tell you where to start. Euler does.

The graph says what to draw but not in what order, and order is what costs time on a plotter. Every pen-up hop is wasted motion.

The emitter leans on a classical result. In any graph, a trail that uses each edge once can only start and finish at a vertex of odd degree — so nodes with an odd number of connections, plus isolated ones, are exactly the places a stroke has to begin or end. Those get drawn first. After each stroke the next start is chosen greedily by distance from where the pen just stopped, which is a cheap approximation to a travelling-salesman ordering and takes most of the slack out of the travel.

Whatever still has unused connections afterwards must have even degree everywhere, which means it lies on a cycle — and a cycle can be entered anywhere, because it is guaranteed to come back. Those get walked second.

Two panels: the pen-down strokes alone forming the portrait, and the same strokes with thin blue pen-up travel lines drawn between them
The emitted tool path. Left, what the pen draws; right, the same run with pen-up travel in blue.
G-code

Two axes, half a millimetre a pixel.

Nodes become coordinates with a swap and a halving — the image column becomes X, the image row becomes a negative Y, both scaled by one half. That scale is the whole calibration: one pixel of the edge map is half a millimetre of paper, so a 301-pixel crop lands as a 150.5 mm square, which is what the machine's work area is set to.

Moves come out as G0 for a rapid with the pen clear and G1 for a cut. A final pass walks the file and injects the pen commands, M5 and M3 S30, at each transition between the two — the servo that lifts and drops the pen is driven off the spindle output, which is the usual trick on a hobby GRBL controller.

The machine.

Two axes, one servo, and a file full of moves.

The finished .nc file goes to a GRBL sender, which previews the whole path over a 150.5 mm grid before the first move — and then the plotter works through it stroke by stroke, lifting between them.

Pipeline.

Capture
Webcam frame in Colab, converted to grayscale
Face detection
OpenCV Haar cascade, scaleFactor 1.2, minNeighbors 5, box enlarged 1.3×
Contrast
CLAHE, clipLimit 5, 8×8 tiles — compared against global equalisation
Denoise
Separable Gaussian, ksize 15, σ 2
Edges
Laplacian, Prewitt, Sobel and Canny compared; Canny (1, 100) carried forward
Binarisation
Per-operator inverse threshold, then a final cut at 35 to a boolean map
Vectorising
Concentric integer circles r = 0…10, run-length arcs, DFS pixel ownership, stroke graph
Ordering
Odd-degree endpoints first with greedy nearest-neighbour hops, cycles second
Output
G0/G1 moves at 0.5 mm per pixel, with M5 / M3 S30 pen commands injected
Machine
GRBL two-axis plotter, servo pen lift, 150.5 mm square work area
Stack
Python, OpenCV, NumPy, SciPy, Matplotlib

The tool-path figures on this page were rendered by running the repository's own vectoriser and G-code emitter over the notebook's edge map — the notebook computes the path but never plots it.

Read the code, or get in touch.