Image dithering
/ 9 min read
Table of Contents
Wat? Image dithering?
To dither, or not to dither, that is the question.
Een van de inspiratiebronnen voor het starten van een blog was de pagina van lowtech.magazine. Als je een keer op de pagina bent geweest, dan kan het je niet ontgaan zijn dat de plaatjes er uniek uitzien:

Zo’n afbeelding heet een dithered image. Dit ziet er in eerste instantie misschien raar uit, maar het heeft ook zijn voordelen. Een van de voordelen is simpel: een dithered image is over het algemeen vele malen kleiner dan het originele plaatje; want heb je het originele plaatje nodig om je lezer de nodige context mee te geven?
Voor mij was het antwoord nee, maar dit verschilt natuurlijk per usecase. In bovenstaand voorbeeld is de orginele afbeelding 989 kb groot terwijl de initieel ingeladen afbeelding maar 83(!) kb is. Dat is bijna 12 keer zo klein. Dat is een enorme stap als we het hebben over largest contenful paint time, zeker naar mate het aantal plaatjes op een pagina toeneemt.

Leuk feitje, de originele NES ROM voor Super Mario is maar 40 kb groot. Je zou dus 2x heel super mario in het geditherede plaatje kunnen stoppen (of 24 keer in de originele afbeelding) in plaats van een afbeelding van zonnepanelen - het is maar waar je prioriteiten liggen ;)
Sommige afbeeldingen zijn puur vormgevend zoals bovenstaande afbeeldingen van zonnepanelen of de super mario bros box, maar soms heb je afbeeldingen waaraan je refereert in je tekst. Denk bijvoorbeeld aan een schematische weergave zoals hier:

De afbeelding is er nog steeds en duidelijk genoeg voor menig lezer om van af te leiden hoe het verkeer door Traefik afgehandeld wordt. Mocht een eindgebruiker toch behoefte hebben aan meer context van de afbeelding, dan kan men op de knop naast de afbeelding drukken. Zo wordt alsnog het origineel ingeladen en gepresenteerd aan de eindgebruiker. Hoe dan ook wordt de pagina initeel sneller geladen en is het aan de eindgebruiker om het origineel op te vragen als hij daar behoefte aan heeft.
Requirements
Ik heb de Astro plugin geschreven om twee redenen:
- Ik wil mijzelf uitdagen om een plugin te schrijven om terug te geven aan de community.
- Ik wil iets technisch uitdagends neerzetten wat nog niet is gebouwd in de community.
De Astro integrations index had geen resultaten voor dithered of dither en github bevatte ook geen sourcecode voor een Astro integration die onderstaande requirements implementeert.
De plugin moet:
- Opzichzelf werken en andere plugins niet in de weg zitten. Dat betekend automatisch PNG’s omzetten naar -dithered.png’s en de originele png met rust laten.
- Makkelijk toepasbaar zijn. Zo min mogelijk configuratie om het initieel werkend te krijgen, maar wel genoeg opties om aan en uit te zetten naar wens van de blogger.
- Afnemers kunnen gebruik maken van meegebakken styling of gemakkelijk wisselen naar custom styling.
- Open source zijn. Dit is wat mij betreft de standaard voor hobbyprojecten zoals dit. Voor meer motivatie waarom opensource hobbyprojecten een goed idee zijn, lees eens dit artikel.
Met deze requirements in het achterhoofd heb ik de astro plugin documentatie erbij gepakt. Links op de monitor de documentatie open, rechts een kersverse opensource github repository en gas geven maar!
Proof of Concept
solar.lowtechmagazine.com heeft in 2023 een post geschreven over de herontwikkeling van de blog. Verder dan een korte anekdote over dat eindgebruikers kunnen togglen tussen originele afbeeldingen en de geditherede afbeeldingen kwam ik helaas niet:
The new design allows the visitor to turn off the dithering compression for individual images, revealing the original photo or illustration.
Tijdens deskresearch kwam ik erachter dat een game developer een uitgebreide blog heeft geschreven over image dithering. In de blog zet schrijver Surma uiteen welke verschillende soorten dithering er zijn in combinatie met behulpzame (geditherde) afbeeldingen. Dit was erg behulpzaam met het zoeken naar het stylistisch juiste dithering algoritme. Ik heb uiteindelijk gekozen voor Bayer dithering.
Gewapend met het juiste algoritme ging ik op zoektocht naar een implementatie van bayer dithering, geschreven in type- of javascript. Een goede tip is om gebruik te maken van google’s ingebouwde filters:
bayer dithering javascript inurl:github
Met inurl:github vraag je aan google om alleen resultaten waarin github in de url voorkomt terug te geven. Handig als je weet dat je op zoek bent naar sourcecode en niet naar blogs met snippets van code. Zo komen we uit op iemand die een library heeft geschreven met een GPLv3 license. Mooi, want de plugin wordt opensource en zo kan ik de code legaal hergebruiken.
Nu we het algorithme hebben, willen we natuurlijk begrijpen hoe de code werkt. Niks beter om de code te leren te begrijpen dan hem te refactoren naar valide TypeScript. Even debuggen, types opschrijven bij inputs en een kind kan de was doen. Daarna heb ik netjes de benodigde functions geexporteerd en gebruikt in mijn integration.ts.
Na het herschrijven en de output te hebben vergeleken met de originele input, bleek dat de image groter was geworden dan voorheen! Dat is natuurlijk niet de bedoeling. Dit ligt aan het feit dat het algorithme te naief is en geen gebruik maakt van PNG pallettes waardoor alsnog elke pixel los opgeslagen wordt in de PNG. Met behulp van een library genaamd optipng kan ik dit alsnog retroactief toepassen door middel van de output te optimalizeren. Zo wordt alsnog de image grootte gereduceerd naar een vele malen factoren kleiner plaatje.

De astro plugin
De astro plugin boilerplate volgde al snel. De plugin heb ik opgezet in drie delen:
- de library met het algoritme om de plaatjes te bewerken
- Een integration die inhaakt op de bovengenoemde hooks
- Een rehype plugin die de markdown bestanden afspeurt op images en ze daar vervangt met HTML.
Dankzij de goede documentatie had ik een hook gevonden (astro:build:start ) om tijdens buildtime een extra stap ertussen te plaatsen voor het verwerken van de lokale afbeeldingen naar dithered.png afbeeldingen. De integration roept de library aan voor elk gevonden plaatje in een nieuwe Promise om zo asynchroon de plaatjes te kunnen verwerken. In eerste instantie had ik dit niet parallel maar sequentieel geschreven. Dit heb ik echter snel herschreven aangezien de plugin wel werkte, maar er gewoon simpel veel te lang over deed voordat de buildstap klaar was.
Als volgt heb ik een rehype plugin geschreven, een plugin die markdown bestanden afspeurt op bepaalde syntax hits en ze vervolgens kan manipuleren. Dit is de standaard html processor binnen Astro. De plugin vervangt de standaard <img>
tag met een <figure>
, een <img>
tag (natuurlijk) en een <button>
om te switchen tussen de originele source en de dithered source.
Met de SVG heeft ChatGPT mij keurig geholpen.

Protip; als het icoontje simpelweg beschreven kan worden in tekstuele vorm aan een LLM, genereer er dan een in plaats van online te gaan zoeken naar een ongelicenseerde SVG.
Vervolgens heb ik op discord dankbaar gebruik gemaakt van de community en ben ik erachter gekomen dat het inladen van de clientside javascript en css ook via een astro-hook (astro:config:setup ) beschikbaar gesteld kan worden. In eerste instantie was ik dit heel moeilijk handmatig aan het inladen via de NPM package, maar dit is een vele malen mooiere oplossing waardoor adoptie van de plugin nog makkelijker gemaakt wordt.
De CSS en JS heb ik express zo lightweight mogelijk proberen te houden. Echt het minimale om het zo aantrekkelijk mogelijk te maken. Het enige wat echt verplicht is en wat ik ook benoem in mijn README.md is dat er een filter geplaatst wordt op de geditherede afbeelding.

NPM

Als laatste, maar zekers niet als minste wilde ik de package natuurlijk distribueren. De standaard voor herbruikbare packages is NPM en dit is iets wat ik ook dagelijks in mijn werk gebruik. Ik had echter nog nooit een eigen package gepubliceerd.
Gelukkig was dit met de handleiding een kinderspel. Een paar commands later en ik had een manueel gepubliceerde package op NPM.
Natuurlijk zat ik niet te wachten op al dat handwerk bij elke nieuwe versie van de plugin. Bij het opzetten van de website had ik ook al gebruik gemaakt van Github actions, dus waarom niet ook voor de plugin? En ja hoor, er is een kant en klare handleiding die dit helemaal voor je kan automatiseren. Geen enkele regel custom yaml
komt eraan te pas. Je kan de voorbeeld code vergelijken met de yaml die ik nu gebruik voor de plugin.
Adoptie
Om de plugin onder de aandacht te brengen van de community, heb ik een post geplaatst op discord:

Daarop kwamen leuke reacties, waaronder:
This is excellent, it also furthers my plan to make a solar powered version of my site!
Ook had ik een vraag uitgezet bij andere plugin ontwikkelaars voor een review van de codebase, maar daar is helaas geen feedback uitgekomen. Ik had het waardevol gevonden als een andere developer had gekeken naar de codebase, zeker omdat het gaat om mijn eerste plugin voor dit framework.
Als laatste was het nodig om keywords te plaatsen in de beschrijving van de package om zo ervoor te zorgen dat de integration automatisch opgenomen kan worden in de integrations index van Astro zelf. De teller staat op het moment van schrijven op 958 downloads. De teller is gebaseerd op het totaal aantal NPM downloads, een niet zo goede metric. De laatste versie van de plugin die 1 week oud is, is 60 keer gedownload - ook een mooi resultaat :)

Conclusie
Al bij al was het een leuk hobbyproject waarbij ik voor het eerst niet alleen zelf nut ervan heb ervaren, maar ook andere gebruikers van het framework er blij mee heb kunnen maken. Ik zou het leuk vinden als er pull-requests op zouden komen en als ik zie dat een andere blogger de plugin ook gebruikt.
Wie weet groeit dit kleine projectje nog uit tot iets groters, samen met anderen die er net zoveel plezier aan beleven als ik. Voor nu kan ik tevreden terug kijken op dit hobby project.
// Brian