Karhulla on asiaa

SVG-tiedostojen optimointi verkkokäyttöön

Kimmo Tapala

Oman Karhuhelsinki.fi-sivustomme alatunnisteen taustalla on aina käytetty tyyliteltyä taustakuvaa, joka koostuu eri sinisen sävyillä väritetyistä kolmioista. Kuvan tarkoituksena on puhtaasti luoda fiilistä, joten sillä ei saisi olla sivuston toiminnan ja käytettävyyden kannalta juuri ollenkaan haitallisia vaikutuksia. Lähtötilanne kyseisen kuvan kanssa oli kuitenkin täysin sietämätön.

Alkuperäinen kuva oli 4500×1600 pikselin kokoinen JPEG-tiedosto, jonka tiedostokoko oli n. 675 kilotavua. Tiedostokoko on mielestäni valtava ja ehdottomasti liian suuri taustakuvalle, jonka tarkoituksena on vain hieman luoda fiilistä. Ongelma korjaantui jossain määrin sillä, että tiedosto ajettiin erinomaisen Jpegoptim-optimointityökalun läpi, jolloin tiedosto kutistui noin 100 kilotavuun. Tämä on varsin hyvä tulos Jpegoptimilta, mutta edelleen tiedoston lataaminen kestäisi yhden megabitin sekuntinopeudella melkein sekunnin. Tiedosto siis oli vieläkin liian suuri sille suunniteltuun käyttöön, mutta rasterikuvana tiedostokoosta alkoi olla hankala saada enää kilotavuja pois laadun kärsimättä.

Koska kuvan sisältö (sinisiä kolmioita) näytti siltä, että se voisi toimia hyvin myös vektorigrafiikkana, päätin kokeilla, kuinka paljon tiedostokoosta saataisiin pois, kun kuva muutettaisiin SVG:ksi. Lopulta kuvan siirtokooksi palvelimelta selaimelle jäi vain reilut kahdeksan kilotavua. Lue alta, miten tähän päästiin.

100 kilotavun optimoitu JPEG-kuva saatiin lopulta kutistettua 8,3 kilotavun SVG:ksi

Sisäkkäisiä kolmioita, Shubham Dhage / Unsplash

Rasterista vektoria

Ensimmäinen vaihe tietysti oli saada JPEG-muotoinen kuva muunnettua SVG:ksi. JPEG on häviöllinen bittikarttakuvien pakkausmuoto, eli se pyrkii kuvaamaan tiiviissä muodossa sitä, minkä värisistä pikseleistä kuva noin suunnilleen koostuu. SVG puolestaan on vektorigrafiikkamuoto, eli se kuvaa objekteja, joista kuva rakentuu. Objektit voivat olla esimerkiksi kaaria, ympyröitä tai monikulmioita. Vektorigrafiikka on resoluutioriippumatonta, joten kuvan pikselimitoilla ei varsinaisesti ole merkitystä – kuvaa voidaan vapaasti skaalata ylös ja alas laadun kärsimättä.

Koska tarpeena oli saada kuva, joka koostuu kolmioista, ajattelin kokeilla jotakin Delaunayn kolmiointitoteutusta. Verkosta löytyy yllättävän paljon kolmiointityökaluja, joista päädyin käyttämään ensimmäisenä hakutuloksissa olevaa härveliä. Parametreja säätelemällä kuvan sai nopeasti varsin kelvolliseksi, mutta valitettavasti ihan maaliin asti ei vielä tällä päästy. Rasterikuvassa ollut kohina nimittäin katosi tietysti siinä vaiheessa, kun kuva muutettiin vektorigrafiikaksi. Onneksi SVG tarjoaa tähän näppärän ominaisuuden!

Kohinaa kuvaan SVG-filtterillä

Rasterikuvaformaatit ovat käytännössä aina binääritiedostoja, eli niiden sisältö on tietyllä rakenteella määriteltyä bittisoppaa. SVG eroaa tästä radikaalisti siinä, että SVG on XML-sovellus, eli SVG-kuvat ovat tekstitiedostoja. Siinä missä rasterikuvien muokkaaminen vaatii kuvankäsittelyohjelman, voi SVG-kuvia muokata näppärästi tekstieditorilla.

SVG:ssä on mahdollista käyttää erilaisia filttereitä, jotka sitten liitetään tiettyihin objekteihin. Alapuolella näet filtterin, jolla kohina luodaan Karhuhelsinki.fi:n alatunnisteen taustakuvaan. Filtterin kanssa käytetään soft-light-sekoitusmoodia, jolla kuvan värisävyt pysyvät kohtuullisen lähellä oikeaa ja kohina ei erotu kuvasta liikaa. Filtteri liitetään g-elementtiin, joka yhdistää kaikki kuvan polkuobjektit yhden loogisen elementin sisään, eli sama filtteri tulee näin käyttöön kaikille kuvassa oleville kolmioille.

<filter id="noise">
    <feTurbulence type="fractalNoise" baseFrequency=".75" numOctaves="2" result="turbulence" stitchTiles="stitch"/>
    <feBlend in="turbulence" in2="SourceGraphic" mode="soft-light"/>
</filter>
<g filter="url(#noise)">
...
</g>

Vaikka kohina on melko pieni juttu, se muuttaa kuvan fiilistä valtavasti. Ilman kohinaa kuva vaikuttaa kliiniseltä ja abstraktilta. Kohina luo kuvaan realismia ja konkretiaa. Ongelmana siinä kuitenkin on, että kohinafiltterin kanssa kuvan piirtäminen on selaimelle melko iso homma. Tästä syystä joillakin selaimilla kuva piirtyy ruudulle pitkähköllä viiveellä.

Siirtokoko ratkaisee

Selaimet osaavat purkaa lennossa pakattuja tekstitiedostoja. Tällä saadaan tiputettua merkittävästi siirrettävän datan määrää ja täten sivustojen latausaikaa ja verkkoresurssien käyttöä. Käytännössä kaikki tekstimuotoinen sisältö, kuten HTML-dokumentit, CSS-tyylit, JavaScript-koodi ja myös SVG-kuvat, siirretäänkin palvelimelta selaimelle pakattuna ja puretaan selaimessa. Yleisimmät pakkausformaatit tähän käyttöön ovat gzip ja Brotli, joista jälkimmäinen on selvästi tehokkaampi. Brotlille ei kuitenkaan valitettavasti ole juuri tällä hetkellä tukea Karhuhelsinki.fi-sivuston palvelinympäristössä, joten joudumme tukeutumaan gzip-pakkaukseen.

Ilman muutoksia lähdetiedostoon, SVG-kuva pakkautuu gzipillä 93 kilotavusta 9,6 kilotavuun, joten gzipilläkin päästään lähes 90% säästöön tiedoston siirtokoossa. Tämä johtuu pääasiallisesti siitä, että gzipin toteuttama DEFLATE-formaatti pakkaa LZSS-algoritmin ja Huffmanin koodauksen ansiosta erittäin tehokkaasti sisältöä, jossa on paljon toistoa. SVG-tiedostomme tapauksessa valtaosa sisällöstä jakautuu kahteen osaan: liukuvärimäärittelyt, jotka kertovat kolmioissa käytettävien liukuvärien asetukset, sekä objektimäärittelyt, jotka kuvaavat yksittäisten kolmioiden kärkipisteiden sijainnit ja viittaavat määriteltyihin liukuväreihin. Objektien optimointi gzip-pakkausta varten on melko mahdotonta, mutta liukuvärimäärittelyihin voidaan saada lisää toistoa.

Suppeampi paletti lisää toistoa

Helppo tapa saada toistoa liukuvärimäärittelyihin on toistaa samoja värejä. Tämä tarkoittaa luonnollisestikin sitä, että kuvassa käytettävää väripalettia supistetaan. Suunnitelma kuvan värien vähentämiseksi oli seuraava:

  1. Poimitaan kuvasta kaikki alkuperäiset värit ja listataan ne tekstitiedostoon. Värien poiminnan yhteydessä voidaan laskea myös värien käyttömäärät, jolloin saadaan tilastotietoa siitä, kuinka paljon toistoa kuvassa tällä hetkellä on.
  2. Luodaan suppea paletti alkuperäisen rasterikuvan perusteella.
  3. Korvataan alkuperäiset värit paletin väreillä siten, että paletista poimitaan väri, joka on lähimpänä alkuperäistä väriä.

Alkuperäisten värien poimiminen SVG:stä onnistui näppärästi *nix-ympäristöissä paljon käytetyllä AWK-työkalulla. AWK-skripti muodostui erittäin yksinkertaiseksi:

#!/usr/bin/awk -f
​
/stop-color/ {
  print $3;
}

AWK-skriptin suorittaminen sylkee tuloksena kaikki tiedoston liukuvärimäärittelyistä löytyvät väriviittaukset. Jotta näistä saadaan selville yksittäisten värien käyttömäärät, voidaan AWK-skripti yhdistää sort– ja uniq-komentoihin seuraavasti: ./listcolors.awk image.svg | sort | uniq -c | sort -r > color-freq.txt. Tulokseksi tulee tekstitiedosto, jossa on listattuna värit käytetyimmästä vähiten käytettyyn. Alkuperäisessä kuvassa uniikkeja värejä oli yhteensä 93 ja niistä käytetyimpään oli viittauksia yhteensä 59 paikassa.

Helppo tapa luoda tuo kakkoskohdan väripaletti on käyttää esimerkiksi jotakin verkosta löytyvää työkalua, joka rakentaa halutulla laajuudella olevan paletin kuvasta löytyvien värien perusteella. Tässä tapauksessa käytin Color Designer -sivuston Color Palette From Image -työkalua ja loin sillä kymmenvärisen paletin.

Värivastaavuuksien etsimiseen rakensin yksinkertaisen PHP-skriptin, joka:

  1. Muuntaa värien heksadesimaaliesityksestä (esim. #013a93) R-, G- ja B-komponenteille desimaaliarvot. RGB-värien heksadesimaaliesityksessä värit esitetään kahden heksadesimaaliluvun nipuissa niin, että esimerkin 01 vastaa värin punaisen (R) värikomponentin desimaaliarvoa 1, 3a vihreän (G) komponentin desimaaliarvoa 58 ja 93 sinisen (B) komponentin desimaaliarvoa 147. Komponenttien desimaaliarvot ovat väliltä 0 – 255.
  2. Laskee kolmiulotteisen etäisyyden (R-, G- ja B-akselit) kohdeväriin jokaiselle paletin värille ja poimii lähimpänä olevan paletin värin.
  3. Tulostaa ulos vastaavuustaulukon, jossa kullakin rivillä listataan alkuperäinen väri ja korvaava väri paletista.

PHP-skriptistä ei tullut kovin elegantti, mutta hoitaa kertakäyttöisen hommansa:

<?php
​
$palette = file('palette.txt');
$colors = file('orig-colors.txt');
​
foreach ($colors as $color) {
  $palette_rgb = array_map(function($palette_color) {
    return dec_rgb(color_hex_rgb($palette_color));
  }, $palette);
  $closest = pick_closest(trim($color), $palette_rgb);
  print trim($color) . ' ' . $closest . "n";
}
​
​
function pick_closest($color, $palette) {
  $c_rgb = dec_rgb(color_hex_rgb($color));
  $distances = array_map(function($p_rgb) use ($c_rgb) {
    $distance = sqrt(pow($p_rgb[0] - $c_rgb[0], 2) + pow($p_rgb[1] - $c_rgb[1], 2) + pow($p_rgb[2] - $c_rgb[2], 2));
    return [
      'distance' => $distance,
      'rgb_color' => '#' . color_hex($p_rgb[0]) . color_hex($p_rgb[1]) . color_hex($p_rgb[2])
    ];
  }, $palette);
​
  usort($distances, function($a, $b) {
    if ($a['distance'] == $b['distance']) {
        return 0;
    }
    return ($a['distance'] < $b['distance']) ? -1 : 1;
  });
​
  return reset($distances)['rgb_color'];
}
​
function color_hex($dec) {
  return str_pad(dechex($dec), 2, '0', STR_PAD_LEFT);
}
​
function color_hex_rgb($color) {
  return str_split(substr($color, 1, 6), 2);
}
​
function dec_rgb($rgb) {
  return array_map(function($hex) {
    return hexdec($hex);
  }, $rgb);
}

Kun värivastaavuudet oli saatu selville, piti värit korvata alkuperäisestä kuvatiedostosta uusilla. Tein tämänkin PHP:lla, koska se tuntui hommaan mukavasti sopivan:

<?php
​
$svg = file_get_contents('image.svg');
$occurences = file('color-replacement.txt');
​
foreach ($occurences as $row) {
  list($original, $replacing) = explode(" ", $row); 
  $svg = str_replace($original, $replacing, $svg);
}
​
echo $svg;

Nyt kuvan palettia oli saatu supistettua merkittävästi ja SVG-tiedoston pitäisi pakkautua entistä paremmin gzipillä. No, gzipillä pakattu SVG-tiedosto oli tämän aherruksen jälkeen pienentynyt 9,6 kilotavusta 8,7 kilotavuun. Jälleen saatiin siis noin 10% tiedostokoosta pois.

SVG-optimoinnilla lisäsäästöä

SVG:ssä voidaan monia asioita tehdä eri tavoilla ja kulloinkin valittu tapa saattaa vaikuttaa merkittävästikin lopullisen SVG-tiedoston kokoon tai pakkautuvuuteen. Lisäksi SVG-tiedostot usein sisältävät ylimääräistä tietoa, jolle ei välttämättä ole tarvetta juuri siinä kontekstissa, missä kuvaa halutaan käyttää. Näiden seikkojen tutkimiseksi ajattelin kokeilla SVGO-työkalua, joka voidaan räätälöidä hyvin spesifisti kuhunkin käyttötarkoitukseen sopivaksi.

Pitkähkön testailuprosessin tuloksena päädyin käyttämään seuraavanlaista SVGO-konfiguraatiota:

module.exports = {
  plugins: [
    "cleanupAttrs",
    "removeXMLProcInst",
    "removeUnknownsAndDefaults",
    "cleanupNumericValues",
    "convertShapeToPath",
    "removeOffCanvasPaths",
  ],
};

Käytetyt SVGO-pluginit ovat:

  • cleanupAttrs: poistaa elementtien attribuuteista ylimääräiset rivinvaihdot ja muun turhan ns. whitespacen.
  • removeXMLProcInst: poistaa tiedostosta XML-prosessointiohjeet. Tämän jälkeen tiedostot eivät enää ole validia XML:ää, mutta selaimet näyttävät SVG:t silti ongelmitta.
  • removeUnknownsAndDefaults: poistaa elementit ja attribuutit, jotka ovat tuntemattomia tai oletusarvoisia.
  • cleanupNumericValues: pyöristää ylimääräisiä desimaaleja pois numeroarvoista ja poistaa oletusarvoisen px-yksikön arvoista, joissa se on mukana.
  • convertShapeToPath: muuntaa tietyt muoto-objektit käyttämään geneeristä path-elementtiä. Tämä vaikuttaa usein pakkautuvuuteen melko paljon, koska se vähentää tiedostossa käytettyjä uniikkeja merkkijonoja.
  • removeOffCanvasPaths: poistaa objektit, jotka ovat näkyvän piirtoalueen ulkopuolella.

Tällä konfiguraatiolla gzip-pakattu tiedostokoko saatiin tiputettua lopulta 8,7 kilotavusta 8,3 kilotavuun. SVGO-plugineja käyttämällä on helppo saada aikaiseksi SVG-tiedostoja, jotka eivät enää toimi oikein kaikissa selaimissa tai jotka vain näyttävät jotenkin täysin väärältä. Osa plugineista myös vaikuttaa negatiivisesti kuvien pakkautuvuuteen, joten konfiguraatio kannattaa viilata kuntoon tapauskohtaisesti. Brotli-pakattuna tämän myllyn läpi käyneen SVG-kuvan tiedostokoko olisi vielä huomattavasti pienempi – vain 6,4 kilotavua.

Yhteenveto

Prosessi ei ollut ihan niin suoraviivainen kuin se tässä blogikirjoituksessa on kuvattuna. Tein erilaisilla palettikorvaustoteutuksilla useita versioita kuvasta ja tutkin niiden ulkoasua sekä pakkautuvuutta. Tässä kuvattu toteutus lopulta osoittautui parhaaksi kompromissiksi.

Ongelmaksi myös osoittautui Applen Safari-selain, joka jätti kohinafiltteriä käyttävän SVG:n kokonaan näyttämättä, jos kuvan koko kasvoi liian isoksi. Tämä ongelma taklautui CSS:llä niin, että alatunnisteen taustakuva vaihdetaan kohinattomaan versioon, kun näkymän leveys kasvaa yli 1996 pikselin.

Pelkästään siirrettävän datamäärän näkökulmasta homma oli erittäin kannattava. 100 kilotavun optimoitu JPEG-kuva saatiin lopulta kutistettua 8,3 kilotavun SVG:ksi ja kukaan ainakaan meillä yrityksen sisällä ei huomannut eroa aikaisempaan. Mielestäni tärkeämpää on kuitenkin se, että tämä osoittaa sen, miksi SVG on formaattina niin mukava: se joustaa käyttötarkoituksen mukaan erinomaisesti ja sen ohjelmallinen käsittely on helppoa.

Tällä viikolla näitä luettiin eniten
  1. Google Analyticsin termit suomeksi
  2. Murupolku verkkosivuilla – 6 syytä, joiden takia se on arvokas
  3. HTTP-virhekoodit – eli mitä näet silloin, jos nettisivu ei toimi kuten odotit
Ota yhteyttä
Tilaa uutiskirje