How my e-reader lost its stripes

✨ Explore this awesome post from Hacker News 📖

📂 **Category**:

💡 **What You’ll Learn**:

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?

Down the rabbit hole: first image bugs#

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.

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.

What Astra’s FFT found

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 annotated photograph, with a magnified patch and brightness profile marking ten fine dither intervals across about twenty screen pixels.

Astra’s annotation of the fine dither texture behind its two-pixel estimate.

Making the stripes measurable#

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:

  • The image’s own dither pattern that had tripped up Astra
  • Whatever was introducing the stripes
  • The X3 screen’s own physical characteristics
  • A handheld phone photo of this mess:
    • Sensor noise
    • Variable focus within an image
    • Lens distortion (these have to be macro shots to capture the 259ppi screen)
    • Lighting and exposure variations, processing artifacts
    • Motion blur from my shaky hands

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.

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?

What was different about grey?#

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.

A test pattern, and a second bug#

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 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.

Three, then four, shades of grey#

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.

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.

A slower way to draw a picture#

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 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

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.

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.

Do we get closure?#

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.

A happy pixelated ending#

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.

🔥 **What’s your take?**
Share your thoughts in the comments below!

#️⃣ **#ereader #lost #stripes**

🕒 **Posted on**: 1789455493

🌟 **Want more?** Click here for more info! 🌟

By

Leave a Reply

Your email address will not be published. Required fields are marked *