I’ve been a lifelong reader, which has proved a fragile habit in our era of endless trivial distraction. While books and e-ink readers solve the problem of competition for attention, they can’t compete with a phone for convenience. That tiny device in my pocket has made reading both easier and more vulnerable to displacement whenever a notification pops up.
A few days ago, I learned for the first time about a recent generation of tiny e-ink readers. Since they’re cheap, it was easy to give into curiosity: I bought an Xteink X3. Not only is it astonishingly tiny, I could immediately install the delightful open-source CrossPoint firmware. I was very quickly able to install a few books. I also appreciated being able to install custom fonts, though I was a little surprised by their indifferent rendering (foreshadowing…).
CrossPoint is very configurable out of the box, so I converted a photo to a dithered black and white bitmap as my “device sleep” screen, and was pleased with how pretty this looked. When I read that the device supported 4 whole shades of grey, I was intrigued: would a greyscale image look better?
This revealed what looked like a bug: the sleep screen was displaying greyscale images with very murky dark areas. Either my middle-aged eyes were finally failing me, or was dark grey rendering as black? I created a quick test image and verified that dark grey really was black, while light grey was extremely pale (almost white). Mildly annoying, but hardly unexpected on a cheap device, and easily worked around: let’s create a three-tone image!
With less of the regenerated image containing large regions of black, I now saw a new bug: in the CrossPoint viewer app, the prior screen contents were still present in ghostly form (only on paler parts of the display, hence me failing to notice the first time around). However, on the same image, the sleep screen didn’t suffer from this problem. This suggested that there were two image renderers making different decisions, and the viewer app’s code was buggy.
In both cases, though, the photograph had distinctive vertical stripes across it that were not present in the bitmap file.

A phone photo of my tri-tone image. The fine vertical stripes are easiest to see in the background.
Since I knew next to nothing about e-ink, ESP32 development, or CrossPoint, I started investigating in the usual late-2026 way, using GPT-6 Astra in Codex. I would capture the X3’s screen on my phone and drop images into my Codex session.
An e-ink screen uses voltage pulses to move black and white pigment particles, which stay in place after the power is removed. An incomplete update or insufficient voltage can leave ghosted traces of the prior image behind.
To display a greyscale image, CrossPoint first draws a black-and-white base in which even the grey pixels start out black. It then runs a short voltage-pulse waveform to move selected pixels partway towards white. This second stage, a “nudge,” produces dark and light shades of grey by driving those pixels for different amounts of time.
Astra found that the viewer did a fast black-and-white update and simply stopped, without ever performing the grey nudge. It quickly fixed the problem.
The stripes proved much more stubborn. Astra initially flailed, blaming the Floyd–Steinberg dithering it had used to prepare my sleep picture. A different algorithm made no difference. It then followed a lead down the stack, and flagged the nudge waveform as worth investigating.
But we were struggling to agree on what artifact we were even looking at or measuring, which concerned me; I didn’t want to burn state-of-the-art tokens chasing phantoms. When I pushed, Astra dug in and reported a stripe pattern two pixels wide. That didn’t make any sense to me, so I asked it to annotate the photograph, and found that it had picked out fine dither texture instead of the bands I could see across the image.
Using a fast Fourier transform (FFT) here was quite clever: it’s an almost ideal tool to pick out and quantify repeating patterns that are hard to measure by eye. Astra applied a two-dimensional FFT to small patches of the photograph and the source image, and found a strong repeat at roughly two screen pixels in both.
Unfortunately, error-diffusion dithering produces structure of its own, by its nature often high-frequency noise that creates a strong signal in an FFT. Astra had picked out the fine dot pattern of Floyd–Steinberg, the very algorithm it had chosen to prepare the image and blamed early in the investigation. The broader bands I was complaining about appeared only on the reader. When I challenged its estimate, it made this annotation, which confirmed that we were looking at different patterns.

Astra’s annotation of the fine dither texture behind its two-pixel estimate.
Slightly frazzled by Astra’s hypotheses that were going nowhere, I switched to Fable 5.1 in Claude Code for another perspective.
I had a hunch that the width of the stripes meant something, but even identifying the stripes had eluded Astra. And this isn’t easy, as a lot of sources introduce patterns and noise:
Warned away from Astra’s naive image processing dead end, Fable wrote code to average the brightness down each column. For the view below, it used a sliding window 200 rows tall: each point became the average of a short vertical strip around it. This averaged away the dither texture, while a brightness difference that persisted down a column would survive. Broad shapes in the picture remained, but the stripes became much easier to see:

A crop after applying the sliding vertical average. The broad shapes belong to the photograph; the fine vertical bands are the defect.
Fable now used an FFT on a one-dimensional brightness profile to measure the spacing and strength of the vertical pattern. Its first guesstimate put the stripes roughly seven screen pixels apart, but this was based on a guess of my photograph’s scale.
It returned its attention to dithering, this time inside the firmware, proposing that repeated rounding errors could line up to produce the vertical bands. When I mentioned that my source image was already dithered, it became more excited, but this ended up being a 20-minute false lead. Another investigation involved Fable getting worked up over the bit depth of an image, but this too led nowhere. At least it was being more novel in its investigations than Astra?
I had also been investigating much simpler images on the device. Removing either the grey or the pattern made the stripes disappear:
| Image | Grey pixels present | Neighbouring pixels in different states | Stripes |
|---|---|---|---|
| Flat grey field | yes | no | none |
| Black-and-white dither | no | yes | none |
| Grey dither, using either of two methods | yes | yes | yes |
The specific combination of grey pixels with neighbours of a different shade was what caused trouble. Fable matched small blocks of the source image to the phone photograph, which finally allowed it to see that light-grey pixels carried the stripe, an important detail that hadn’t even been clear to me due to the very pale tone of light-grey pixels.
This evidence now pointed towards how the screen produced grey, via the greyscale nudge. The code for this lives in freeink-sdk, the hardware library CrossPoint uses.
Fable was initially reluctant to go further: “I cannot design or validate a LUT change from here.” A LUT is the lookup table holding the nudge waveform. When I pointed out that the X3 was on my desk, and I could photograph whatever a new build displayed, it came back with experiments we could run.
The X3’s odd choice of a magnetic contact charger worked in our favour here. Fable could flash and reboot the device over USB while it was plugged in. I could then pick the device up to photograph the screen and put it back down without ever needing to fiddle with a USB-C connector.
The existing nudge lasted seven scan cycles: each time, the controller worked through the panel’s rows, applying the next step of the voltage sequence to each pixel. We still thought the stripe spacing was about seven pixels. Could the timing be showing up as a spatial pattern? Changing the waveform’s duration would give us something to compare.
First we needed a better image to measure, so I suggested to Fable that it should generate a test image. It created a pattern with flat patches at all four shades, mixtures of grey with black or white, a checkerboard, and lines running in both directions.

The test pattern. Flat patches establish the four shades; the patterned areas test how grey behaves beside other shades. The checkerboard is at the right of the third row.
The test image made progress dramatically easier. Fable knew how the image should look, so variations in my photos and the screen’s appearance became possible to see and account for. For example, Fable’s original scale estimate had mistaken a feature in the photograph’s spectrum for the screen’s pixel grid. With the test pattern as a ruler, the stripe period turned out to be eight pixels, rather than seven.
I supplied raw DNG files from my phone as well as processed photographs. Comparing the two showed that the phone’s processing exaggerated the stripe amplitude by about 70 percent and shifted the apparent grey levels. We used raw files after that. Fable worked out how to locate the test patches despite changes in framing, perspective, and lens distortion, so it could measure each build and photo in the same way.
We tried stretching the nudge from seven frames to ten, then a version that interleaved drive pulses with rests. Neither affected the stripes. This ruled out the proposed connection between the frame count and the stripe period.
The test pattern also brought back a problem we’d worked around earlier.

The test pattern under the original waveform. The first two patches in the top row should be black and dark grey. Both are black. The dark-grey-on-black patch at the left of the third row has disappeared too.
Our supposedly four-shade reader was definitely displaying three shades; this wasn’t just my aging eyes. Dark grey was black. I was lucky enough to be watching Fable doing its thinking when it found this, because it only reported this as an offhand drive-by comment. Understanding the significance of its finding, I had to jump in, stop it, and get it to dig in deeper.
Fable traced this to a disagreement between CrossPoint and the driver about how to request dark grey. CrossPoint sends two bits per pixel for the nudge, selecting one of four waveform tables. Its code for dark grey selected a table that did nothing. The intended dark-grey drive was sitting in a different table. Fable fixed the mistake and issued a PR against freeink-sdk.
With that fixed, dark grey appeared, and the patch that should have been dark grey dithered on black became a visible speckle instead of a solid black square. But the light-grey drive hadn’t changed, and neither had the stripes. We had opportunistically fixed another bug while building the test for the first one.
The missing shade had also been making text look rough (remember that mention of indifferent text rendering?). CrossPoint anti-aliases its text, so pixels intended to soften the edges with dark grey had been coming out black, yielding chonky text. Restoring that shade improved text throughout the reader.
The driver contained another waveform we hadn’t tried: the manufacturer’s four-grey image mode, called XTH4. It used a much longer sequence of pulses to produce the four shades, taking roughly a second to refresh. That would be an annoying delay on every page turn, but seemed reasonable for opening a picture or drawing the sleep screen.
The table was already in freeink-sdk, and another reader’s driver used a version of it for images. We could simply try it on the X3 without inventing a waveform from scratch. Fable was not at all sure this would achieve anything, but I urged it to forge ahead.
There was a memory problem to solve first. The nudge only needs to distinguish dark grey, light grey, and “leave this pixel alone”. Black and white can share that last instruction because the first pass has already drawn them. The longer waveform needs separate instructions for all four shades.
The other reader’s driver solved this by keeping a copy of the black-and-white image in RAM and combining it with the grey data. My X3 uses an ESP32-C3 with about 380 kilobytes of RAM, and its largest free block was only 53 kilobytes. At 528 by 792 pixels, even a one-bit copy of the screen would consume 52 kilobytes. There wouldn’t be room to casually add another buffer.
Fable proposed having CrossPoint draw the data in the required format to begin with. The image viewer already decoded the file once for each rendering pass; those passes could produce two bits per pixel that identified all four shades. The driver would receive what it needed without another screenful of data.
With this all implemented and flashed, I opened the image that had kicked off the investigation, and told Fable: “The stripes ARE NO LONGER visible in my original why are there stripes? image.”

The photograph on my X3, before and after. Below the line it uses the original grey waveform; above it uses the new one.
The measurements agreed. The column variation dropped from roughly four percent of the black-to-white range to one percent. The peak at eight pixels disappeared entirely from the frequency spectrum.

Column brightness relative to the black-to-white range, with gridlines every eight screen pixels. The regular oscillation in the upper plot disappears in the lower one
The longer waveform also had the happy side effect of improving the shades themselves. Restoring dark grey had given us four levels; this also pulled light grey further from white (remember my comments about it being very pale?). Dark grey is a bit darker than I’d like, so there’s still some tuning to do.

Measured shades under the original firmware, after restoring dark grey, and under the longer waveform. The dotted lines mark evenly spaced brightness levels for comparison.
So this is a half-satisfying investigation: replacing the code fixed the problem, but do we really know why it occurred in the first place? Not quite.
Here is my best guess, based on my newly acquired and still very limited knowledge of electrophoretic displays. The rows of an e-ink panel are switched by a gate driver etched into the glass. A handful of interleaved clocks drive the gates, each opening every nth row for a slightly different interval of time. A particle being driven hard to black or white is insensitive to timing variation. A pixel nudged partway to grey over three frames gets only the charge its row’s time window allows, so every eighth row comes out a little lighter or darker.
When a column is charged, this happens one row at a time, and its voltage only has to move when consecutive rows are different shades. If a grey pixel follows a black one, that voltage swing has to complete inside our brief and variable time window. A flat grey field never swings, while a black-and-white swings, but the optical response saturates, so timing doesn’t matter so much. Only when we have a grey dither do we see a swing and sensitivity to timing, and that’s when we get our stripes.
Do I know that this is true? No, but it fits the electrical architecture and the evidence I have. I could have pursued this further, but I was relieved to have non-stripy imagery.
The driver change became freeink-sdk#95. Both of my freeink-sdk pull requests were merged within hours. I submitted a couple of CrossPoint PRs too. Amusingly, someone else gazumped me there, by merging an almost identical set of changes right before mine got reviewed.
I find the timescale of all this a little ridiculous. A few hours after unboxing my first e-ink device, I was testing fixes to its display waveforms. Astra and Fable handled unfamiliar code, build tools, and measurement scripts, while I could concentrate on what the experiments ought to do, provide major hints like use of FFTs and test patterns, quibble and steer around investigative dead ends, and share what the screen actually showed.
This experience of being able to jump into the unknown and immediately make progress was quite exciting. Even though I didn’t know much about e-ink, CrossPoint, or ESP32 in the beginning, I learned a lot within hours. It’s definitely whetted my appetite for more of this sort of low-level device hacking.