Hoe mijn e-reader zijn strepen verloor
Een paar dagen geleden hoorde ik voor het eerst over een recente generatie minuscule e-ink readers. Omdat ze goedkoop zijn, was het makkelijk om mijn nieuwsgierigheid te volgen: ik kocht een Xteink X3. Niet alleen is hij verbazingwekkend klein, maar ik kon er direct de fijne open-source CrossPoint-firmware op installeren. Al snel kon ik een paar boeken installeren. Ook waardeerde ik de mogelijkheid om aangepaste lettertypen te installeren, hoewel ik een beetje verrast was door de matige rendering (een voorbode van wat komen zou...).
CrossPoint is direct na installatie zeer configureerbaar. Ik converteerde een foto naar een geditherde zwart-wit bitmap als "slaapscherm" voor het apparaat en was tevreden over hoe mooi dit eruitzag. Toen ik las dat het apparaat vier volledige grijstinten ondersteunde, werd ik nieuwsgierig: zou een grijswaardenafbeelding er beter uitzien?
Het konijnenhol in: de eerste beeldfouten
Dit onthulde wat leek op een bug: het slaapscherm toonde grijswaardenafbeeldingen met zeer troebele donkere gebieden. Faalden mijn ogen op middelbare leeftijd eindelijk, of werd donkergrijs weergegeven als zwart? Ik maakte een snelle testafbeelding en bevestigde dat donkergrijs inderdaad zwart was, terwijl lichtgrijs extreem bleek was (bijna wit). Mild irritant, maar nauwelijks onverwacht bij een goedkoop apparaat, en eenvoudig te omzeilen: laten we een afbeelding met drie tinten maken!
Nu de geregenereerde afbeelding minder grote zwarte gebieden bevatte, zag ik een nieuwe bug: in de CrossPoint-viewer-app waren de vorige scherminhoud nog in spookachtige vorm aanwezig (alleen op de blekere delen van het display, waardoor ik het de eerste keer niet had opgemerkt). Echter, op dezelfde afbeelding had het slaapscherm dit probleem niet. Dit suggereerde dat er twee image renderers waren die verschillende beslissingen namen, en dat de code van de viewer-app buggy was.
In beide gevallen had de foto echter kenmerkende verticale strepen die niet aanwezig waren in het bitmap-bestand.
Bijschrift: Een foto met de telefoon van mijn tri-tone afbeelding. De fijne verticale strepen zijn het best te zien in de achtergrond.
Omdat ik vrijwel niets wist over e-ink, ESP32-ontwikkeling of CrossPoint, begon ik mijn onderzoek op de gebruikelijke manier van eind 2026, met GPT-6 Astra in Codex. Ik maakte foto's van het scherm van de X3 met mijn telefoon en sleepte deze afbeeldingen in mijn Codex-sessie.
Een e-ink scherm gebruikt spanningspulsen om zwarte en witte pigmentdeeltjes te verplaatsen, die op hun plek blijven nadat de stroom is uitgeschakeld. Een onvolledige update of onvoldoende spanning kan spookachtige sporen van het vorige beeld achterlaten.
Om een grijswaardenafbeelding weer te geven, tekent CrossPoint eerst een zwart-wit basis waarin zelfs de grijze pixels beginnen als zwart. Daarna voert het een korte spanningspuls-golfvorm uit om geselecteerde pixels gedeeltelijk naar wit te verplaatsen. Deze tweede fase, een "nudge", produceert donkere en lichte tinten grijs door die pixels gedurende verschillende tijdsintervallen aan te sturen.
Astra ontdekte dat de viewer een snelle zwart-wit update deed en simpelweg stopte, zonder ooit de grijze nudge uit te voeren. Dit probleem werd snel opgelost.
De strepen bleken veel hardnekkiger. Astra tastte aanvankelijk in het duister en gaf de Floyd–Steinberg dithering de schuld, die was gebruikt om mijn slaapscherm voor te bereiden. Een ander algoritme maakte geen verschil. Daarna volgde Astra een spoor verderop in de stack en gaf aan dat de nudge-golfvorm het waard was om te onderzoeken.
Maar we hadden moeite om overeen te komen over welk artefact we precies bekeken of maten, wat me zorgen baarde; ik wilde geen state-of-the-art tokens verspillen aan het jagen op fantoomproblemen. Toen ik aandrong, ging Astra dieper graven en rapporteerde een streeppatroon van twee pixels breed. Dat sloeg voor mij nergens op, dus vroeg ik om de foto te annoteren. Ik ontdekte dat Astra de fijne dither-textuur had aangestippeld in plaats van de banden die ik over de afbeelding zag.
Wat de FFT van Astra vond
Het gebruik van een Fast Fourier Transform (FFT) was hier vrij slim: het is een bijna ideaal instrument om herhalende patronen te identificeren en te kwantificeren die met het blote oog moeilijk te meten zijn. Astra paste een tweedimensionale FFT toe op kleine fragmenten van de foto en de bronafbeelding, en vond in beide een sterke herhaling van ongeveer twee schermpixels.
Helaas produceert error-diffusion dithering van nature zijn eigen structuur, vaak hoogfrequente ruis die een sterk signaal in een FFT veroorzaakt. Astra had het fijne stippatroon van Floyd–Steinberg opgepikt, precies het algoritme dat het zelf had gekozen om de afbeelding voor te bereiden en waar het eerder in het onderzoek de schuld aan gaf. De bredere banden waar ik me over klaagde, verschenen alleen op de reader. Toen ik de schatting betwistte, maakte Astra deze annotatie, wat bevestigde dat we naar verschillende patronen keken.
Bijschrift: Astra's annotatie van de fijne dither-textuur achter zijn schatting van twee pixels.
De strepen meetbaar maken
Iets gefrustreerd door de hypothesen van Astra die nergens toe leidden, stapte ik over op Fable 5.1 in Claude Code voor een ander perspectief.
Ik had het vermoeden dat de breedte van de strepen iets betekende, maar zelfs het identificeren van de strepen was Astra ontgaan. En dat is niet makkelijk, omdat veel bronnen patronen en ruis introduceren:
- Het eigen ditherpatroon van de afbeelding (dat Astra in de war bracht).
- Wat dan ook dat de strepen veroorzaakte.
- De fysieke kenmerken van het X3-scherm.
- Een handmatige telefoonfoto van deze chaos:
- Sensorruis.
- Variabele focus binnen een afbeelding.
- Lensvervorming (er zijn macro-opnames nodig om het 259ppi scherm vast te leggen).
- Variaties in belichting en belichting, verwerkingsartefacten.
- Bewegingsonscherpte door mijn trillende handen.
Gewaarschuwd voor de naïeve beeldverwerking van Astra, schreef Fable code om de helderheid per kolom te middelen. Voor de weergave gebruikte het een schuivend venster van 200 rijen hoog: elk punt werd het gemiddelde van een korte verticale strook eromheen. Hierdoor verdween de dither-textuur, terwijl een helderheidsverschil dat over een hele kolom aanhield, zou overleven. Brede vormen in de foto bleven behouden, maar de strepen werden veel gemakkelijker te zien.
Bijschrift: Een uitsnede na het toepassen van het schuivende verticale gemiddelde. De brede vormen horen bij de foto; de fijne verticale banden zijn het defect.
Fable gebruikte nu een FFT op een eendimensionaal helderheidsprofiel om de afstand en sterkte van het verticale patroon te meten. De eerste schatting plaatste de strepen ongeveer zeven schermpixels uit elkaar, maar dit was gebaseerd op een gok van de schaal van mijn foto.
Vervolgens richtte Fable de aandacht weer op dithering, ditmaal binnen de firmware, met het voorstel dat herhaalde afrondingsfouten konden samenvallen om de verticale banden te produceren. Toen ik noemde dat mijn bronafbeelding al geditherd was, werd Fable enthousiaster, maar dit bleek een doodlopend spoor van 20 minuten te zijn. Een ander onderzoek betrof de bitdiepte van een afbeelding, maar ook dat leidde nergens toe. Althans, het was origineler in zijn onderzoeken dan Astra.
Wat was er anders aan grijs?
Ik had ook veel simpelere afbeeldingen op het apparaat onderzocht. Het verwijderen van óf het grijs óf het patroon zorgde ervoor dat de strepen verdwenen:
| Afbeelding | Grijze pixels aanwezig | Naburige pixels in verschillende staten | Strepen |
|---|---|---|---|
| Plat grijs vlak | ja | nee | none |
| Zwart-wit dither | nee | ja | none |
| Grijs dither (twee methoden) | ja | ja | yes |
De specifieke combinatie van grijze pixels met buren van een andere tint was wat de problemen veroorzaakte. Fable koppelde kleine blokken van de bronafbeelding aan de telefoonfoto, waardoor het uiteindelijk kon zien dat lichtgrijze pixels de streep droegen—een belangrijk detail dat voor mijzelf niet eens duidelijk was vanwege de zeer bleke toon van lichtgrijze pixels.
Dit bewijs wees nu in de richting van hoe het scherm grijs produceerde, via de greyscale nudge. De code hiervoor bevindt zich in freeink-sdk, de hardwarebibliotheek die CrossPoint gebruikt.
Fable was aanvankelijk terughoudend om verder te gaan: "Ik kan vanaf hier geen LUT-wijziging ontwerpen of valideren." Een LUT is de lookup table (opzoektabel) die de nudge-golfvorm bevat. Toen ik aangaf dat de X3 op mijn bureau stond en dat ik foto's kon maken van wat een nieuwe build zou tonen, kwam het terug met experimenten die we konden uitvoeren.
De vreemde keuze van de X3 voor een magnetische contactoplader werkte hier in ons voordeel. Fable kon het apparaat flashen en herstarten via USB terwijl het was aangesloten. Ik kon het apparaat vervolgens oppakken om het scherm te fotograferen en het weer neerzetten zonder ooit met een USB-C connector te hoeven worstelen.
Een testpatroon en een tweede bug
De bestaande nudge duurde zeven scan-cycli: elke keer werkte de controller de rijen van het paneel af en paste de volgende stap van de spanningssequentie toe op elke pixel. We dachten nog steeds dat de afstand tussen de strepen ongeveer zeven pixels was. Zou de timing zich kunnen uiten als een ruimtelijk patroon? Het wijzigen van de duur van de golfvorm zou ons iets geven om mee te vergelijken.
Eerst hadden we een betere afbeelding nodig om te meten, dus stelde ik Fable voor om een testafbeelding te genereren. Het maakte een patroon met vlakke delen in alle vier de tinten, mengsels van grijs met zwart of wit, een schaakbord en lijnen in beide richtingen.
Bijschrift: Het testpatroon. Vlakke delen bepalen de vier tinten; de gepatteerde gebieden testen hoe grijs zich gedraagt naast andere tinten. Het schaakbord bevindt zich rechts op de derde rij.
De testafbeelding maakte de voortgang dramatisch makkelijker. Fable wist hoe de afbeelding eruit moest zien, waardoor variaties in mijn foto's en het uiterlijk van het scherm zichtbaar en verklaarbaar werden. Zo had Fable's oorspronkelijke schatting van de schaal een kenmerk in het spectrum van de foto aangezien voor het pixelraster van het scherm. Met het testpatroon als liniaal bleek de periode van de streep acht pixels te zijn, in plaats van zeven.
Ik leverde zowel ruwe DNG-bestanden van mijn telefoon als bewerkte foto's aan. De vergelijking tussen die twee liet zien dat de verwerking van de telefoon de amplitude van de strepen met ongeveer 70 procent overdreef en de schijnbare grijsniveaus verschoof. Daarna gebruikten we alleen nog ruwe bestanden. Fable werkte uit hoe het de testpatronen kon lokaliseren ondanks veranderingen in kadrering, perspectief en lensvervorming, zodat elke build en foto op dezelfde manier gemeten kon worden.
We probeerden de nudge uit te rekken van zeven frames naar tien, en daarna een versie die aanstuurpulsen afwisselde met rustpauzes. Geen van beide beïnvloedde de strepen. Dit sloot de voorgestelde connectie tussen het aantal frames en de periode van de streep uit.
Drie, en vervolgens vier grijstinten
Het testpatroon bracht ook een probleem terug waar we eerder een omweg voor hadden gezocht.
Bijschrift: Het testpatroon onder de oorspronkelijke golfvorm. De eerste twee vlakken in de bovenste rij zouden zwart en donkergrijs moeten zijn. Beiden zijn zwart. Het vlak met donkergrijs op zwart links op de derde rij is ook verdwenen.
Onze vermeende vier-tinten reader toonde definitief slechts drie tinten; dit waren niet alleen mijn verouderende ogen. Donkergrijs was zwart. Ik had het geluk dat ik Fable kon zien nadenken toen het dit ontdekte, want het rapporteerde dit slechts als een terloopse opmerking. Realiserend hoe significant deze ontdekking was, moest ik ingrijpen, het stoppen en het dwingen om dieper te graven.
Fable spoorde dit terug naar een onenigheid tussen CrossPoint en de driver over hoe donkergrijs moet worden aangevraagd. CrossPoint stuurt twee bits per pixel voor de nudge, waarbij een van de vier waveform-tabellen wordt geselecteerd. De code voor donkergrijs selecteerde een tabel die niets deed. De bedoelde aansturing voor donkergrijs stond in een andere tabel. Fable herstelde de fout en diodeerde een PR (pull request) naar freeink-sdk.
Nadat dit was opgelost, verscheen donkergrijs, en het vlak dat donkergrijs geditherd op zwart had moeten zijn, werd een zichtbare spikkel in plaats van een massief zwart vierkant. Maar de aansturing voor lichtgrijs was niet veranderd, en de strepen ook niet. We hadden opportunistisch een andere bug opgelost terwijl we de test voor de eerste bug bouwden.
De ontbrekende tint had er ook voor gezorgd dat tekst er ruw uitzag (herinner je de vermelding van matige tekstrendering?). CrossPoint past anti-aliasing toe op tekst, waardoor pixels die bedoeld waren om de randen met donkergrijs te verzachten, zwart werden, wat resulteerde in "blokkerige" tekst. Het herstellen van die tint verbeterde de tekst in de hele reader.
Een tragere manier om een beeld te tekenen
De driver bevatte nog een golfvorm die we niet hadden geprobeerd: de vier-grijs-beeldmodus van de fabrikant, genaamd XTH4. Deze gebruikte een veel langere sequentie van pulsen om de vier tinten te produceren, wat ongeveer een seconde duurde om te verversen. Dat zou een irritante vertraging zijn bij elke bladzijde omslaan, maar leek redelijk voor het openen van een afbeelding of het tekenen van het slaapscherm.
De tabel stond al in freeink-sdk, en de driver van een andere reader gebruikte een versie hiervan voor afbeeldingen. We konden dit simpelweg op de X3 proberen zonder zelf een golfvorm vanaf nul te moeten bedenken. Fable was er helemaal niet zeker van dat dit iets zou opleveren, maar ik drong aan om door te zetten.
Er was eerst een geheugenprobleem op te lossen. De nudge hoeft alleen onderscheid te maken tussen donkergrijs, lichtgrijs en "laat deze pixel met rust". Zwart en wit kunnen die laatste instructie delen, omdat de eerste pass ze al heeft getekend. De langere golfvorm heeft aparte instructies nodig voor alle vier de tinten.
De driver van de andere reader loste dit op door een kopie van de zwart-wit afbeelding in het RAM-geheugen te houden en deze te combineren met de grijze data. Mijn X3 gebruikt een ESP32-C3 met ongeveer 380 kilobytes RAM, en het grootste vrije blok was slechts 53 kilobytes. Bij een resolutie van 528 bij 792 pixels zou zelfs een kopie van het scherm in één bit 52 kilobytes verbruiken. Er zou geen ruimte zijn om zomaar een extra buffer toe te voegen.
Fable stelde voor om CrossPoint de data vanaf het begin in het vereiste formaat te laten tekenen. De image viewer decodeerde het bestand al één keer voor elke render-pass; die passes konden twee bits per pixel produceren die alle vier de tinten identificeerden. De driver zou krijgen wat hij nodig had zonder een extra scherm aan data.
Nadat dit was geïmplementeerd en geflasht, opende ik de afbeelding die het onderzoek had gestart en zei ik tegen Fable: "De strepen zijn NIET MEER zichtbaar in mijn originele afbeelding."
Bijschrift: De foto op mijn X3, voor en na. Onder de lijn wordt de oorspronkelijke grijze golfvorm gebruikt; erboven de nieuwe.
De metingen waren consistent. De variatie per kolom zakte van ongeveer vier procent van het zwart-wit bereik naar één procent. De piek bij acht pixels verdween volledig uit het frequentiespectrum.
Bijschrift: Kolomhelderheid relatief aan het zwart-wit bereik, met rasterlijnen om de acht schermpixels. De regelmatige oscillatie in de bovenste grafiek verdwijnt in de onderste.
De langere golfvorm had ook het gelukkige neveneffect dat de tinten zelf verbeterden. Het herstellen van donkergrijs had ons vier niveaus opgeleverd; dit trok lichtgrijs verder weg van wit (herinner je mijn opmerkingen dat het erg bleek was?). Donkergrijs is iets donkerder dan ik zou willen, dus er is nog wat tuning nodig.
Bijschrift: Gemeten tinten onder de oorspronkelijke firmware, na het herstellen van donkergrijs, en onder de langere golfvorm. De stippellijnen markeren gelijkmatig verdeelde helderheidsniveaus ter vergelijking.
Komen we tot een conclusie?
Dit is dus een half-bevredigend onderzoek: het vervangen van de code loste het probleem op, maar weten we echt waarom het in eerste instantie gebeurde? Niet helemaal.
Dit is mijn beste gok, gebaseerd op mijn nieuw verworven en nog zeer beperkte kennis van elektroforetische displays. De rijen van een e-ink paneel worden geschakeld door een gate driver die in het glas is geëtst. Een handvol in elkaar gevlochten klokken stuurt de gates aan, waarbij elke klok elke n-de rij opent voor een iets verschillend tijdsinterval. Een pixel die hard naar zwart of wit wordt gestuurd, is ongevoelig voor timingvariaties. Een pixel die over drie frames gedeeltelijk naar grijs wordt "genudged", krijgt alleen de lading die het tijdvenster van zijn rij toelaat, waardoor elke achtste rij iets lichter of donkerder uitvalt.
Wanneer een kolom wordt opgeladen, gebeurt dit rij voor rij, en de spanning hoeft alleen te verschuiven wanneer opeenvolgende rijen verschillende tinten hebben. Als een grijze pixel volgt op een zwarte, moet die spanningssprong voltooid worden binnen ons korte en variabele tijdvenster. Een vlak grijs veld verschuift nooit, terwijl een zwart-wit beeld wel verschuift, maar de optische respons verzadigt, waardoor timing minder belangrijk is. Alleen bij een grijze dither zien we een verschuiving en gevoeligheid voor timing, en dat is wanneer we onze strepen krijgen.
Weet ik zeker dat dit waar is? Nee, maar het past bij de elektrische architectuur en het bewijs dat ik heb. Ik had dit verder kunnen uitzoeken, maar ik was opgelucht om beelden zonder strepen te hebben.
Een gelukkig, gepixelde einde
De wijziging in de driver werd freeink-sdk#95. Beide van mijn freeink-sdk pull requests werden binnen enkele uren samengevoegd. Ik diende ook een paar CrossPoint PR's in. Amusant genoeg werd ik daar door iemand anders voor zijn voeten gelopen, die een bijna identieke set wijzigingen merge liet uitvoeren vlak voordat de mijne werden beoordeeld.
Ik vind de tijdschaal van dit alles een beetje belachelijk. Een paar uur na het uitpakken van mijn eerste e-ink apparaat, testte ik fixes voor de display-golfvormen. Astra en Fable hanteerden onbekende code, build-tools en meetscripts, terwijl ik me kon concentreren op wat de experimenten moesten doen, belangrijke hints kon geven zoals het gebruik van FFT's en testpatronen, kon discussiëren en sturen rond doodlopende wegen, en kon delen wat het scherm daadwerkelijk liet zien.
Deze ervaring om in het onbekende te kunnen springen en direct vooruitgang te boeken was erg spannend. Hoewel ik in het begin niet veel wist over e-ink, CrossPoint of ESP32, heb ik binnen enkele uren veel geleerd. Het heeft zeker mijn eetlust gewekt voor meer van dit soort low-level device hacking.
Groetjes,