La vectorisation d'image (Image Trace) a l'air magique, mais elle tient en trois petites étapes : décider quels pixels appartiennent à quelle couleur, découper un contour autour de chaque zone de couleur, puis ajuster le moins de points possible sur chaque contour. L'outil Image Trace de l'éditeur vectoriel (Vector Editor) de l'application de bureau osFoundry le fait en un clic. Voici la même chaîne écrite en Python, exécutée sur un logo PNG de 220 × 220 px (un cercle vert, une tasse crème, un nuage orange, les mots KUMO COFFEE) et mesurée à chaque étape, puis comparée au SVG que l'application a exporté à partir du même fichier. Le code fait environ 110 lignes avec numpy, Pillow et scikit-image.
Le PNG a 2 110 couleurs, le logo en a trois
Le logo a été dessiné avec un vert, un crème, un orange et le blanc de la page, puis réduit de 1 200 px à 220 px et légèrement flouté, ce qui correspond à l'aspect de la plupart des logos sur les sites web. On compte les couleurs distinctes :
uniq, counts = np.unique(px.astype(np.uint8), axis=0, return_counts=True)
# 220x220 px, 2110 distinct colours
Presque toutes les 2 110 sont des teintes intermédiaires le long des bords. Les trois plus fréquentes sont #ffffff (20 574 pixels), #1f5f4a (14 938) et #faf4e6 (3 747). Vectoriser consiste surtout à se débarrasser des 2 000 autres.
Le noir et blanc est un seuil, et il décide plus qu'on ne le voudrait
La vectorisation par défaut de l'application est en noir et blanc. C'est un seuil sur la luminance :
lum = a @ [0.299, 0.587, 0.114]
black = lum < 128 # 38.7% of the pixels
L'orange du nuage (#e87834) a une luminance d'environ 146 ; il tombe donc du côté clair et devient blanc, et dans la vectorisation par défaut de l'application le nuage disparaît dans le fond. Déplacer le seuil entre 96 et 160 ne fait varier la part de pixels noirs que de 37,1 % à 43,3 %. Aucun seuil ne ramène le nuage, parce qu'il est plus clair que le vert et plus sombre que la page, et qu'un seuil n'a que deux côtés.
Trois couleurs, c'est du clustering
Avec trois couleurs, la question devient : de laquelle des trois chaque pixel est-il le plus proche ? C'est un k-means avec k = 3 sur les valeurs RVB des 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
Il s'exécute en 100 ms environ. Les tailles des groupes sont 38,7 % de vert, 56,2 % de crème plus page et 5,1 % d'orange, et la distance moyenne entre un pixel et la couleur de son groupe est de 13, sur une échelle dont le maximum possible est 441.
Les moyennes des groupes ne sont pourtant pas les couleurs du logo. La moyenne du vert est #22614c contre un #1f5f4a réel, celle de l'orange est #c98651 contre un #e87834 réel, parce que les pixels flous le long du bord appartiennent au groupe et tirent sa moyenne vers les voisins. En prenant, au lieu de la moyenne, la couleur réelle la plus fréquente de chaque groupe, on obtient #1f5f4a, #ffffff et #e87834. Le SVG exporté par l'application utilise #1f5f4a, #e87834 et #fefdfa : elle fait donc quelque chose de plus solide qu'une simple moyenne, sans que je puisse dire quoi à partir du résultat.
Des contours à la précision du sous-pixel
Chaque couleur devient un masque, et c'est avec le contour du masque que ça devient intéressant. Un masque coupé au pixel donne des contours en escalier. Si l'on agrandit d'abord le masque par 4 en interpolation bicubique et qu'on coupe à 0,5, le contour tombe là où le flou est à mi-chemin entre les deux couleurs, c'est-à-dire là où se trouvait le vrai bord :
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
Le logo a été dessiné à partir d'un cercle de rayon 91,67 (dans ces unités de 220 px), on dispose donc d'une référence pour vérifier. Sur le bord du cercle, loin des lettres, le contour vectorisé est en moyenne à 0,47 px du vrai cercle, et 95 % de ses points sont à moins de 0,69 px. Le bord flou du PNG, lui, met 2 px pour passer de 90 % de vert à 10 %. La vectorisation retrouve le bord mieux que l'image ne le montre.
Les poussières sont l'autre moitié du travail. Sans surface minimale, le phénomène d'oscillation du bicubique autour des lettres produit 288 contours ; avec MIN_AREA = 4 px², il en reste 62. C'est l'idée derrière le réglage de bruit des outils de vectorisation.
Moins de points
Un contour sorti tel quel de marching squares a un point toutes les fractions de pixel. Douglas-Peucker supprime les points qui se trouvent dans une tolérance de la droite entre leurs voisins :
s = measure.approximate_polygon(contour, tol)
| tolérance | points sur les 62 contours |
|---|---|
| 0,05 px | 4 262 |
| 0,25 px | 1 594 |
| 0,5 px | 945 |
À 0,25 px, les polygones ont été écrits en SVG : 22,6 Ko, avec 1 594 points reliés par des segments droits.
Ce que les courbes apportent
Le SVG exporté par l'application a 324 points d'ancrage répartis en 17 tracés et pèse 11,9 Ko. Il a moins d'un quart des points de la version en polygones, car entre deux ancres il trace une courbe de Bézier au lieu d'une droite, et un cercle tient en quelques courbes plutôt qu'en des centaines de segments. C'est pourquoi un outil de vectorisation finit par un ajustement de courbes et non par le polygone.
Mais l'essentiel, c'est l'indépendance vis-à-vis de la taille. Voici ce que donne l'agrandissement du PNG en interpolation bicubique, puis son enregistrement :
| largeur | PNG | bord, de 90 % à 10 % |
|---|---|---|
| 220 px | 15 Ko | 2 px |
| 880 px | 120 Ko | 7 px |
| 1 760 px | 325 Ko | 14 px |
Le bord vectoriel du cercle vectorisé, dessiné à 1 760 px, fait 1 px de large, et le fichier garde la même taille. Il n'est plus petit que le PNG que lorsque le PNG est grand : le SVG de l'application pèse 11,9 Ko contre 15,0 Ko pour le PNG à la taille d'origine, et 12 Ko contre 325 Ko à 1 760 px.
Ce qu'elle ne peut pas corriger
Une vectorisation suit l'image qu'on lui donne. Une lettre bavée donne un contour bavé, un dégradé doit être aplati en bandes pour passer l'étape des couleurs, et la couleur de la page est une couleur comme une autre : le blanc de la page ressort donc comme une forme. Pour un logo à quelques aplats de couleur et aux bords nets, c'est le bon outil. Pour une photographie, c'est un filtre de postérisation.
L'ensemble
trace.py s'exécute de bout en bout en 3 secondes environ sur un Apple M1 (l'étape k-means, environ 100 ms) et affiche tous les chiffres de cet article. Lancez-le avec uv run --with numpy --with pillow --with scikit-image python trace.py logo.png sur n'importe quel petit PNG en aplats de couleur, puis comparez l'erreur de contour et le nombre de points avec ce que donne un outil de vectorisation. Le logo de cet article est une marque de café inventée, et ses chiffres ont été mesurés sur ce seul fichier : les valeurs exactes seront différentes sur le vôtre. L'application de bureau, Vector Editor compris, est gratuite sur la page de téléchargement d'osFoundry.

Top comments (0)