Redraws a photograph as a single closed loop that never crosses itself, after Robert Bosch, Opt Art, chapter 3 — TSP art.

The image is converted to a field of ink density, points are scattered in proportion to that density, the points are relaxed until they are evenly spaced for their local darkness, and then a travelling-salesman tour visits every point once and returns to its start. Darkness in the finished drawing is the density of the wandering line, not the darkness of any stroke.

ariadne -n 20000 -w 1.4 cameraman.png drawing.svg

Continuous-line drawing of the cameraman photograph

Its name is Ariadne’s thread — one unbroken line through the labyrinth.

The one thing to understand first

Point count decides whether the image resolves. Stroke width is uniform, so at low point counts the drawing is just a mesh with no tonal range. Around 20,000 points is where a 512×512 photograph becomes recognizable. 5,000 does not, and no other setting will rescue it:

ariadne -n 5000 -w 1.4 cameraman.png

Under-resolved drawing at 5000 points

You can see the line wandering pleasantly and the cameraman failing to appear. If your drawing looks like this, raise -n before changing anything else.

Usage

ariadne [OPTIONS] <INPUT> [OUTPUT]

<INPUT> is any PNG, JPEG, TIFF, or BMP. [OUTPUT] defaults to the input name with an -ariadne.svg suffix; a PNG is written alongside it at the same base name.

The SVG is a real vector path. It scales to any size without pixellating, and a pen plotter will draw it directly.

The pipeline

Each run reports timings per stage, so the vocabulary is worth having:

Stage Name What happens
S0 tone Image → ink density field (sRGB-linearized luminance)
S1 stipple Density → points, drawn in proportion to ink
S2 relax MacQueen relaxation + Lloyd polish → blue noise
S3 partition Optionally split points into groups (--groups)
S4 tour Order each group into a tour
S5 render Write SVG and PNG

Options

The essentials

Option Default Meaning
-n, --points <N> 20000 Number of stipple points
-g, --gamma <G> 2.0 Density field gamma
-w, --linewidth <W> 0.5 Stroke width, in image pixels
-r, --resolution <PIXELS> 1200 Output width in pixels

Line width interacts with resolution. The default 0.5 is tuned for the default 1200-pixel output. Render larger without adjusting -w and the strokes go sub-pixel and wash out to pale gray; render much smaller and they blot together. As a rule of thumb, scale -w roughly with -r. Every example on this page uses -w 1.4 at -r 900.

--gamma shapes the mapping from brightness to ink. Above 2.0 deepens the shadows and empties the highlights; below 2.0 flattens toward mid-gray.

Shaping the density field

Option Default Meaning
--floor <F> 0.0 Snap density below this to exactly zero
--unsharp-sigma <S> 0.0 Unsharp mask radius; 0 disables
--unsharp-amount <A> 0.0 Unsharp mask strength

--floor is how you get genuinely blank paper, and it is the most under-appreciated option in the tool. With the default of 0.0 nothing is floored, so light regions still receive a thin scatter of points and the line meanders through what should be empty sky — filling the whole frame with even texture and flattening the subject into it.

ariadne -n 20000 --floor 0.25 -w 1.4 cameraman.png

Drawing with floor 0.25, showing an emptied sky

Compare that with the default render at the top of this page. The same 20,000 points, redistributed: the sky is now true white, and the points it would have wasted there have gone into the coat, the tripod, and the grass. The subject reads far more clearly.

How high to set it depends on the picture. Small values do very little on an image whose light areas are already near-white — on cameraman, --floor 0.05 moves total ink by less than 0.1%, while 0.25 moves it by nearly a quarter. Raise it until the background empties, and stop before it starts eating the subject.

--unsharp-sigma and --unsharp-amount sharpen before stippling, which can recover edge definition that the density mapping softens. Both must be non-zero to have any effect.

Relaxation

Option Default Meaning
-e, --epochs <N> 60 Relaxation epochs
--polish <N> 2 Lloyd polish iterations
--schedule <S> power Learning rate: power, harmonic, or constant

Relaxation is what converts a random scatter (white noise) into an evenly spaced one (blue noise), and it is most of what makes the drawing look deliberate rather than accidental. The defaults are sound; raising -e past 60 yields progressively less.

The run reports cv_nn, the coefficient of variation of nearest-neighbor distances. Lower means more evenly spaced. Values around 0.44 are typical of a well-relaxed field.

Tour construction

--solver <S> (default tsp)

  • tsp — near-optimal and crossing-free. Produces roughly 23% shorter tours.
  • hilbert — orders points along a space-filling curve. Faster, and reproduces output from before the TSP solver existed.

tsp is the better drawing and the right default. Use hilbert only to reproduce old output or when tour construction time matters — on 20,000 points the TSP stage is the slowest part of the run at around 2.5 seconds, while Hilbert is effectively instantaneous, taking the whole run from 2.9 s to 0.35 s.

The run reports valid=true, confirming the tour visits every point exactly once and returns to its start.

Subtours

By default the drawing is one closed loop. --groups splits it into several, which lets you ink them differently.

Option Default Meaning
-k, --groups <N> 1 Number of subtours
--partition <MODE> tone tone, spatial, or labels
--mask <PNG> Region map for --partition labels
  • tone — split by darkness, so each subtour carries one tonal band.
  • spatial — split by position.
  • labels — split by a supplied --mask PNG whose flat color regions define the groups. The region count must equal --groups.
ariadne -n 20000 -k 3 --partition tone -w 1.4 \
        --ink "#c1272d,#0e7c7b,#2e4057" cameraman.png

Three-color subtour drawing

Three tonal bands, three inks: the darkest band is drawn in red, the mid band in teal, the lightest in navy. Because the split is by tone, the colors track the image’s structure rather than cutting across it.

The labels mode is the manual-control path: each region of the mask becomes its own subtour, which is how you get a specific object drawn in a specific color. You can paint that mask by hand, or generate one — see Masking a photograph below.

A mask can be either of two things.

A label image — single-channel, where the pixel value is the region number, as ITK-SNAP and other medical imaging tools write — is read directly: value 0 is group 1, value 1 is group 2, and so on. The display colour is only a display colour. This is the encoding to prefer, because the group order is stated rather than inferred.

An RGB mask is grouped by sorting its distinct colours as (r,g,b) tuples. That makes the order an accident of the palette: with a red/green/blue palette the subject painted red comes last, and repainting a region a different shade silently reorders the inks. tools/to_labels.py converts one to a label image, numbering regions largest-area first so the background lands at group 1:

a-to_labels masks/m.png masks/m_labels.png

Color

Option Default Meaning
--ink <COLOR[,COLOR...]> black Ink color, or one per subtour
--background <COLOR> white Background color

Colors are parsed as CSS, so the whole Color Level 4 surface works: named colors, #rgb / #rrggbb / #rrggbbaa, rgb(), rgba(), hsl(), hwb(), lab(). Supply one ink per subtour, comma-separated, in group order — commas inside a functional color are not treated as separators:

ariadne --ink 'rgb(230,234,236), rgba(92,16,24,0.6)' -k 2 ... photo.jpg

green is CSS green — half intensity, #008000. The full-intensity green an additive triple wants is lime. Both the SVG and the PNG resolve colors the same way, so the two outputs match; earlier versions wrote the name into the SVG verbatim and the browser’s CSS interpretation could differ from the PNG.

Alpha works in both outputs. A translucent ink composites over the paper and over whatever has already been drawn, so overlapping subtours blend where they meet rather than the later one simply covering the earlier:

ariadne --ink '#5c101880' ... photo.jpg     # half-strength ink
ariadne --ink 'rgba(92,16,24,0.5)' ... photo.jpg

Measured on one image, dropping alpha walks the drawn pixel from the ink’s own colour toward the paper: #5c1018(92,16,24), cc(125,64,70), 80(173,135,139), 40(214,195,197). --background takes alpha too, though a translucent background over an opaque PNG canvas mostly just lightens it.

Reproducibility and inspection

--seed <N> (default 42) makes a run repeatable within Rust. It does not reproduce Julia’s output — the two use different generators. See Fidelity.

--dry-run reports the resolved configuration and exits:

input       cameraman.png  512x512 px
points      20000
solver      tsp
gamma       2  floor 0
epochs      60  schedule power  polish 2
seed        42  (Rust-only; see --help)
linewidth   0.5  resolution 1200
output      cameraman-ariadne.svg

(dry run — nothing sampled, relaxed, or rendered)

Masking a photograph

ariadne places ink by darkness, so it needs the subject to differ in tone from what surrounds it. On a photograph where it does not — a figure whose clothing matches the wall behind them — no combination of --gamma, --floor or point count recovers the figure. The render comes out an even scribble, because there is no tonal signal saying where the subject is.

A mask supplies that missing division. The repository ships tools for building one under DigitalArt/OptArt/tools/:

pip install rembg onnxruntime
./DigitalArt/OptArt/tools/install.sh    # puts the tools on PATH as a-<name>

a-tsp photo.jpeg        # segment and validate, then stop
# check masks/photo_mask_overlay.png — red should cover the subject, nothing else
a-tsp photo.jpeg --go   # render

install.sh symlinks the tools into ~/.cargo/bin beside ariadne and truchet, so they run from any directory. It also pins each script’s interpreter to one that actually has the dependencies — #!/usr/bin/env python3 resolves against PATH at run time, and the first python3 is often not where pip install rembg landed.

Segmentation mask shown over the source photograph Two-subtour drawing produced from that mask

The mask (left) assigns every pixel to a group; the drawing (right) gives each group its own subtour and ink. Two groups here — subject and background — so the background can be dropped to a faint gray while the subject carries the weight.

Two models are available. The default is fast and suits an image whose subject already stands clear; --hard selects a stronger one for the cases that need it — a subject sharing tone with its surroundings, deep shadow, or wispy edges. On an easy image the two agree almost exactly; on a hard one the smaller model tends to absorb adjacent shadow into the subject, fusing parts that should read separately.

The mask sets grouping, not density. Which subtour a pixel joins comes from the mask; how much ink it gets still comes from the image. When two things belong in the same group but should read differently — a solid object inside a diffuse one — darken that object in a copy of the photograph and render from the copy, leaving the mask alone.

More than two groups

The model finds one subject and one background, so every further subtour comes from subdividing one of those:

a-tsp photo.jpeg --bands 3 --go        # 4 groups
a-tsp photo.jpeg --split 0.45 --go     # 3 groups
a-tsp photo.jpeg --bands 3 --split 0.45 --go   # 5 groups

--bands N cuts the subject into N tonal bands at quantiles of its own luminance, each becoming its own subtour with its own ink — the figure gains internal modelling instead of reading as one flat silhouette. --split FRAC cuts the background with a horizontal line at FRAC of the height. The wrapper counts the groups the mask actually has and builds a matching --ink list: pale backgrounds first, then a dark-to-light ramp across the bands.

Tonal bands leave long chords. A band appears in patches all over the figure — highlights on a face, a shoulder, a sleeve — and its single closed tour must travel between them, drawing a straight line each time. More bands means thinner slices, more fragmentation, and more chords: on one portrait, three bands gave 14 such crossings and five gave 21. Raising the point count makes this worse, not better, since finer stippling fragments each band further. Two or three bands is usually the sweet spot; --split, whose regions are contiguous, has no such problem.

Colouring the line from the photograph

--ink gives each subtour one flat colour. tools/recolor.py takes the finished SVG instead and colours every segment from the photograph along that segment’s own length, so the line becomes a continuous colour field while remaining the same single non-crossing tour:

ariadne --mask masks/m.png --partition labels --groups 2 --ink '#000,#000' \
        -n 60000 -g 2.2 --floor 0.04 -w 1.6 photo.jpg tour.svg
a-recolor photo.jpg tour.svg out.svg \
        --paper '#0a0c0e' --lift --saturation 2.6

--dominant snaps each segment toward whichever RGB channel leads — pure primaries where the photograph is saturated, grey where it is neutral — giving a three-ink separation look from one curve. --dom-gamma 0.6 is bold, 1.4 keeps more detail.

--lift and a dark --paper are what make it work. ariadne puts ink where the photograph is dark, so a faithful sample paints dark cloth near-black and leaves the vivid highlights as a few pale lines — on one image 81% of segments came out below saturation 30. Lifting each segment to full value preserves hue, and a dark ground gives it something to read against.

Point count is colour resolution here, since each segment carries one colour — but lines finer than the raster can resolve average together. The same 180,000-point drawing reads worse than 60,000 at 1400 px wide and better at 2800 px. The SVG is resolution-independent; only raster exports are affected.

Fixing a mask

Three problems put long straight chords in a drawing, and each has a tool.

Strays. Painting over a boundary leaves fragments of the old colour behind. They are invisible — 0.02% of the image — but a tour must visit every point in its group, so a 4-pixel speck 1594 px away drags the line there and back:

a-clean_mask masks/m.png masks/m_clean.png

Islands. A region in several disconnected pieces makes one tour hop between them. a-split_regions gives each piece its own group, repeating the ink so the palette is unchanged, and writes a label image:

a-split_regions masks/m_clean.png masks/m_split.png --ink "Ivory,Crimson"

Necks. Two halves of a region joined by a hairline are one group, so a single tour serves both and crosses the picture. a-check_mask reports them — on one mask the background narrowed to 3 px at the frame edge, and cutting it took the longest chord from 2370 px to 1155 px.

Run them in that order: cleaning after splitting leaves the strays merged into their neighbour, and the tour still has to visit them.

To edit a mask by hand, a-edit_mask photo.jpg masks/m.png paints label values directly over the photograph — no antialiasing to undo afterwards.

Watching the drawing appear

The tour is a sequence, so a film of it being drawn is just progressive prefixes:

a-animate tour.svg tour.mp4 --seconds 25 --width 1080

Animate the monochrome tour, not a recoloured SVG: recolouring replaces the polylines with one <line> per segment and the drawing order is lost.

See DigitalArt/OptArt/tools/README.md for the palette, the antialiasing requirement, and hand-editing.

Performance

On the 512×512 cameraman image, Apple Silicon:

Points Solver Total of which the tour
20,000 tsp 0.5 s 0.13 s
60,000 tsp 1.8 s 0.95 s
60,000 tsp, -k 4 1.1 s 0.16 s
20,000 hilbert 0.3 s

Rendering is essentially free (0.02–0.06 s). In the Julia implementation the same drawing takes 10.3 seconds, 9.5 of it inside Makie’s SVG writer.

Point count costs roughly quadratic time, and the cost is per subtour. Splitting the work with --groups therefore buys speed as well as color: the 60,000-point row above is 6× faster across four subtours than as one, because each tour carries a quarter of the points and the four are solved in parallel.

If you want to iterate on composition before committing to a slow render, --solver hilbert is near-instant at any point count. Its tours are about 23% longer and less elegant, but the picture’s density and framing read the same.

Troubleshooting

The drawing is a mesh and the subject never appears. Point count is too low. Raise -n toward 20,000 or beyond.

The lines are pale and washed out. -w is too small for your -r. Scale line width with resolution.

Light areas are full of wandering line. Set --floor to a small positive value so near-white becomes exactly white.

The output differs from Julia’s for the same seed. Expected for the random stages. See Fidelity.