Image tracing sounds like magic, and it is three small steps: decide which pixels belong to which colour, cut an outline around each colour region, and fit as few points as possible to each outline. The Image Trace tool in the Vector Editor of the osFoundry desktop app does this in one click. Below the same pipeline is written out in Python, run on a 220 × 220 px PNG logo (a green circle, a cream cup, an orange cloud, the word KUMO COFFEE) and measured at each step, then compared with the SVG the app exported from the same file. The code is about 110 lines with numpy, Pillow and scikit-image.
The PNG has 2,110 colours, the logo has three
The logo was drawn with a green, a cream, an orange and a white page, then shrunk from 1,200 px to 220 px and blurred slightly, which is how most logos on websites look. Counting the distinct colours in it:
uniq, counts = np.unique(px.astype(np.uint8), axis=0, return_counts=True)
# 220x220 px, 2110 distinct colours
Almost all of the 2,110 are the in-between shades along the edges. The three most common are #ffffff (20,574 pixels), #1f5f4a (14,938) and #faf4e6 (3,747). Tracing is mostly the job of getting rid of the other 2,000.
Black and white is a threshold, and it decides more than you'd like
The default trace in the app is black and white. That is a threshold on luminance:
lum = a @ [0.299, 0.587, 0.114]
black = lum < 128 # 38.7% of the pixels
The orange of the cloud (#e87834) has a luminance of about 146, so it lands on the light side and turns white, and in the app's default trace the cloud disappears into the background. Moving the threshold between 96 and 160 only changes the share of black pixels from 37.1% to 43.3%. No threshold brings the cloud back, because it is lighter than the green and darker than the page and a threshold only has two sides.
Three colours is clustering
With three colours the question becomes which of three colours each pixel is closest to. That is k-means with k = 3 over the RGB values of all 48,400 pixels:
def kmeans(px, k, it=20):
c = [px[np.argmin(px.sum(1))]] # darkest pixel first
while len(c) < k: # then the farthest from the chosen ones
d = np.min([((px - ci) ** 2).sum(1) for ci in c], axis=0)
c.append(px[np.argmax(d)])
c = np.array(c)
for _ in range(it):
lab = np.argmin(((px[:, None, :] - c[None]) ** 2).sum(2), axis=1)
c = np.array([px[lab == j].mean(0) for j in range(k)])
return c, lab
It runs in about 100 ms. The cluster sizes are 38.7% green, 56.2% cream plus page and 5.1% orange, and the average distance from a pixel to its cluster colour is 13 on a scale where the maximum possible is 441.
The cluster means are not the logo's colours, though. The green mean is #22614c against a true #1f5f4a, and the orange mean is #c98651 against a true #e87834, because the blurred pixels along the edge belong to the cluster and pull its average toward the neighbours. Taking the most frequent real colour in each cluster instead of the mean gives #1f5f4a, #ffffff and #e87834. The SVG the app exported uses #1f5f4a, #e87834 and #fefdfa, so it does something sturdier than a plain mean, though I can't tell what from the output.
Outlines at sub-pixel precision
Each colour becomes a mask, and the outline of the mask is where the interesting part happens. A mask cut at one pixel gives staircase outlines. Upscaling the mask by 4 with bicubic interpolation first and cutting at 0.5 puts the outline where the blur is half way between the two colours, which is where the true edge was:
def contours(mask, up=4):
m = Image.fromarray((mask * 255).astype(np.uint8)).resize((W * up, H * up), Image.BICUBIC)
cs = measure.find_contours(np.pad(np.asarray(m) / 255.0, 1), 0.5)
return [(c - 1) / up for c in cs] # back to source-pixel units
The logo was drawn from a circle of radius 91.67 (in these 220 px units), so there is a ground truth to check against. On the rim of the circle, away from the lettering, the traced outline is on average 0.47 px from the real circle, and 95% of its points are within 0.69 px. The blurred edge in the PNG, by comparison, takes 2 px to go from 90% green to 10% green. The tracing recovers the edge to better than the picture shows it.
The speckles are the other half of the work. Without a minimum area, the bicubic ringing around the lettering produces 288 contours; with MIN_AREA = 4 px² there are 62 left. That is the idea behind the noise setting in trace tools.
Fewer points
A contour straight out of the marching squares has a point every fraction of a pixel. Douglas-Peucker removes the points that sit within a tolerance of the straight line between their neighbours:
s = measure.approximate_polygon(contour, tol)
| tolerance | points on all 62 outlines |
|---|---|
| 0.05 px | 4,262 |
| 0.25 px | 1,594 |
| 0.5 px | 945 |
At 0.25 px the polygons were written out as an SVG: 22.6 KB, with 1,594 points joined by straight segments.
What curves buy
The SVG the app exported has 324 anchor points in 17 paths and weighs 11.9 KB. It has fewer than a quarter of the points of the polygon version, because between two anchor points it draws a Bézier curve instead of a straight line, and a circle is a few curves rather than hundreds of segments. That ratio is why a trace tool ends with curve fitting and not with the polygon.
The point of all this, though, is size independence. Scaling the PNG up with bicubic interpolation and saving it:
| width | PNG | edge, 90% to 10% |
|---|---|---|
| 220 px | 15 KB | 2 px |
| 880 px | 120 KB | 7 px |
| 1,760 px | 325 KB | 14 px |
The vector edge of the traced circle, drawn at 1,760 px, is 1 px wide, and the file is the same size as before. It is only smaller than the PNG when the PNG is big: the app's SVG is 11.9 KB against a 15.0 KB PNG at the original size, and 12 KB against 325 KB at 1,760 px.
What it can't fix
A trace follows the picture it is given. A smudged letter gives a smudged outline, a gradient has to be flattened into bands to get through the colour step, and the page colour is a colour like any other, so the white of the page comes out as a shape. For a logo with a few flat colours and clean edges this is the right tool. For a photograph, it is a posterising filter.
The whole thing
trace.py runs end to end in about 3 seconds on an Apple M1 (the k-means step about 100 ms), and prints every number in this post. Run it with uv run --with numpy --with pillow --with scikit-image python trace.py logo.png on any small flat-colour PNG and compare the outline error and the point counts with what a trace tool gives. The logo in this post is a made-up café mark, and its numbers were measured on this one file, so the exact figures will differ on yours. The desktop app, including the Vector Editor, is free on the osFoundry download page.

Top comments (0)