Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/boot-logo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Boot / Sleep Logo

The logo shown by `BootActivity` and `SleepActivity` lives in
`src/images/Logo256.h`, generated from the source art `src/images/Logo256.png`
by `scripts/convert_logo.py`. Do not edit the header by hand.

## Regenerating

```bash
# Defaults: reads src/images/Logo256.png, writes src/images/Logo256.h at 256x256
python scripts/convert_logo.py

# Explicit input, size, and an optional preview PNG (on-screen appearance)
python scripts/convert_logo.py src/images/Logo256.png 256 /tmp/logo_preview.png
```

Requires Pillow (`scripts/requirements.txt`). To swap the artwork, replace
`src/images/Logo256.png` and rerun the script.

For artwork drawn on a solid black background (like the original badge
export), `scripts/prepare_logo_source.py` produces the transparent source
PNG:

```bash
python scripts/prepare_logo_source.py path/to/original.png # then convert_logo.py
```

## Source art requirements

The source is a square grayscale PNG with an alpha channel. Transparency
defines the background: pixels with alpha below 128 are omitted at draw time,
letting the page (white on boot/LIGHT sleep, black on dark sleep) show
through. The converter makes no assumption about the artwork's shape.

For the current badge art, `prepare_logo_source.py` flood-filled the
original's solid black background to transparent from the borders (the
interior black sky is not border-connected, so it survives) and stroked the
opaque region's boundary with a black ring of uniform thickness. The stroke
follows the artwork's real, slightly non-circular edge -- no circle fitting
-- giving the badge a boundary on the white page while disappearing into the
black one.

No pre-rotation is applied: the logo is rendered through
`GfxRenderer::drawImage2Bit`, which draws via `drawPixel` and is therefore
orientation-correct (unlike `drawImage`, which copies bytes in panel-native
orientation and requires pre-rotated data, as `scripts/convert_icon.py` does
for the small UI icons).

## Format: packed 2-bit grayscale plus opacity mask

The header contains two arrays:

- `Logo256`: `size * size / 4` bytes of tones, 4 pixels per byte, MSB pair
first, gray levels 0 (black) to 3 (white) -- the same row layout as the
2-bit BMP path in `Bitmap`/`drawBitmap`.
- `Logo256Mask`: `size * size / 8` bytes of opacity, 8 pixels per byte, MSB
first, 1 = opaque. Built from the source alpha (>= 128 is opaque).
Transparent pixels store tone 3 so they stay unflagged in the grayscale
planes even if drawn without the mask.

`GfxRenderer::drawImage2Bit` decodes them at draw time according to the
current render mode, skipping transparent pixels in every mode and deriving
each pass the differential grayscale refresh needs:

| Render mode | Draws (where opaque) |
|-----------------|--------------------------------------------|
| `BW` | black where level < 3, white where level 3 |
| `GRAYSCALE_LSB` | 1-bit where level == 1 |
| `GRAYSCALE_MSB` | 1-bit where level == 1 or 2 |

**Boot renders 1-bit only, for speed.** The grayscale refresh costs two 48 KB
plane copies over SPI plus a custom-LUT gray refresh -- around a second at
exactly the moment the user is waiting for the device to come up.
`BootActivity` instead draws a single BW pass with `bwWhiteThreshold = 2`
(light gray renders white, the best standalone 1-bit rendition) and never
touches grayscale state.

**Sleep renders the full 4 levels.** `SleepActivity` draws the BW base (grays
render black), refreshes, then renders each gray plane into the framebuffer
(`setRenderMode` + `drawImage2Bit`, `copyGrayscale{Lsb,Msb}Buffers`) and runs
`displayGrayBuffer()`, which lifts level 1 to dark gray and level 2 to light
gray. Gray levels render black in the base because the differential grayscale
LUT drives a fixed waveform per plane-bit pair: a gray-flagged pixel must
start black on the panel or its final tone is wrong.

After the grayscale display, the activity re-renders the BW frame and calls
`cleanupGrayscaleWithFrameBuffer()`, which re-syncs the controller's RED RAM
with the real frame. Skipping this leaves the grayscale planes in controller
RAM, and the next differential refresh compares against them and ghosts the
logo. This is the same re-render-then-cleanup pattern as
`XtcReaderActivity`'s grayscale path (which avoids the 48 KB
`storeBwBuffer`/`restoreBwBuffer` alternative used by the EPUB reader).

The artwork renders with the same tones on both the white (LIGHT) and black
(dark/inverted) sleep backgrounds: the logo is drawn after `invertScreen()`,
and its transparent background simply lets either page show through.

## Changing the size

- The size must be a multiple of 8 (the mask packs 8 pixels per byte).
- Update `logoSize` in `BootActivity.cpp` and `SleepActivity.cpp`, and the
array names if you change them (`Logo<size>`, `Logo<size>Mask`).
- Flash cost is `3 * size * size / 8` bytes (24 KB at 256); RAM cost is zero.
Decode happens per pixel at draw time, which is negligible next to the
e-ink refresh and only runs on the boot and sleep screens.

## Verifying on device

Flash and watch the boot screen: a single fast 1-bit render (light grays show
white). Then check the sleep screen in both modes: the gray tones (rainbow
trail, moon shading) appear one grayscale refresh after the black-and-white
pass, with identical tones in both modes -- LIGHT on a white page, dark mode
as a round badge on the black page. After waking from sleep, the home screen
and reader should show no logo ghosting.
40 changes: 40 additions & 0 deletions lib/GfxRenderer/GfxRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,46 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
}

void GfxRenderer::drawImage2Bit(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
const uint8_t mask[], const uint8_t bwWhiteThreshold) const {
// Hoist the per-mode predicate out of the pixel loop (same dispatch as
// drawBitmap's 2-bit path). For the grayscale planes, "flagged" pixels get
// a 1 bit (drawPixel state=false), everything else 0.
uint8_t flagA = 0;
uint8_t flagB = 0;
switch (renderMode) {
case GRAYSCALE_LSB:
flagA = flagB = 1;
break;
case GRAYSCALE_MSB:
flagA = 1;
flagB = 2;
break;
case BW:
break;
}

const int rowBytes = width / 4;
const int maskRowBytes = width / 8;
for (int imgY = 0; imgY < height; imgY++) {
const uint8_t* row = bitmap + imgY * rowBytes;
const uint8_t* maskRow = mask ? mask + imgY * maskRowBytes : nullptr;
for (int imgX = 0; imgX < width; imgX++) {
if (maskRow && !(maskRow[imgX / 8] & (0x80 >> (imgX % 8)))) {
continue;
}
const uint8_t val = row[imgX / 4] >> (6 - ((imgX * 2) % 8)) & 0x3;
bool black;
if (renderMode == BW) {
black = val < bwWhiteThreshold;
} else {
black = !(val == flagA || val == flagB);
}
drawPixel(x + imgX, y + imgY, black);
}
}
}

void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/GfxRenderer/GfxRenderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ class GfxRenderer {
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
bool roundBottomLeft, bool roundBottomRight, Color color) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
// Draws a packed 2-bit grayscale image (4 pixels/byte, MSB pair first,
// levels 0=black..3=white). BW mode renders levels below bwWhiteThreshold
// black (default 3 keeps grays black, as a grayscale base pass requires);
// GRAYSCALE_LSB/MSB render the differential grayscale planes. mask, if
// given, is a packed 1-bit opacity plane (8 pixels/byte, MSB first,
// 1 = opaque); transparent pixels are skipped in every mode. Unlike
// drawImage, content is orientation-correct (drawn via drawPixel).
void drawImage2Bit(const uint8_t bitmap[], int x, int y, int width, int height, const uint8_t mask[] = nullptr,
uint8_t bwWhiteThreshold = 3) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const;
void drawBitmap1Bit(Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
Expand Down
139 changes: 139 additions & 0 deletions scripts/convert_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Convert the boot/sleep logo source PNG into a 4-level grayscale C header.

Unlike scripts/convert_icon.py (small 1-bit thresholded UI icons), the logo is
emitted as two arrays:

- Logo<size>: packed 2-bit tones, 4 pixels/byte, MSB pair first,
levels 0 (black) .. 3 (white) -- the same row layout as
Bitmap's 2-bit BMP path
- Logo<size>Mask: packed 1-bit opacity, 8 pixels/byte, MSB first,
1 = opaque

GfxRenderer::drawImage2Bit decodes them at draw time, rendering the BW base
or a differential grayscale plane depending on the current render mode and
skipping masked-out pixels, so the page background shows through transparent
regions on both light and dark screens. Content is drawn through drawPixel,
which handles screen orientation; no pre-rotation is needed.

The source PNG's alpha channel defines the mask (alpha >= 128 is opaque); a
source without alpha converts fully opaque. The converter makes no assumption
about the artwork's shape -- background presentation is carried entirely by
the source asset's transparency. Transparent pixels store tone 3 (white) so
they stay unflagged in the grayscale planes even if drawn without the mask.

See docs/boot-logo.md for the full regeneration workflow.
"""

import os
import sys

USAGE = 'Usage: python scripts/convert_logo.py [input.png [size [preview.png]]]'


def build_levels_and_opacity(input_path, size):
"""Return two size x size matrices: gray levels 0-3 and opacity booleans."""
from PIL import Image

img = Image.open(input_path).convert('LA').resize((size, size), Image.LANCZOS)
gray = img.getchannel('L').load()
alpha = img.getchannel('A').load()
levels = []
opacity = []
for y in range(size):
lrow = []
orow = []
for x in range(size):
opaque = alpha[x, y] >= 128
lrow.append(min(3, (gray[x, y] + 32) // 64) if opaque else 3)
orow.append(opaque)
levels.append(lrow)
opacity.append(orow)
return levels, opacity


def pack_2bit(levels, size):
data = bytearray()
for y in range(size):
for xb in range(size // 4):
byte = 0
for i in range(4):
byte |= levels[y][xb * 4 + i] << (6 - 2 * i)
data.append(byte)
return data


def pack_1bit(opacity, size):
data = bytearray()
for y in range(size):
for xb in range(size // 8):
byte = 0
for i in range(8):
if opacity[y][xb * 8 + i]:
byte |= 0x80 >> i
data.append(byte)
return data


def emit_array(f, name, comment, data):
f.write(f'// {comment}\n')
f.write(f'static const uint8_t {name}[] = {{\n')
vals = [f'0x{b:02x}' for b in data]
per_line = 19 # 4-space indent + 19 values fits ColumnLimit 120, clang-format stable
for i in range(0, len(vals), per_line):
f.write(' ' + ', '.join(vals[i:i + per_line]) + ',\n')
f.write('};\n')


def write_preview(levels, opacity, size, path):
"""Composite on white and black side by side: the on-screen appearance
of the light and dark screens."""
from PIL import Image

prev = Image.new('L', (size * 2 + 30, size + 20), 128)
for panel, bg in enumerate((255, 0)):
img = Image.new('L', (size, size))
px = img.load()
for y in range(size):
for x in range(size):
px[x, y] = levels[y][x] * 85 if opacity[y][x] else bg
prev.paste(img, (10 + panel * (size + 10), 10))
prev.save(path)
print(f'Wrote {path}')


def main():
if any(arg in ('-h', '--help') for arg in sys.argv[1:]):
print(USAGE)
sys.exit(0)
if len(sys.argv) > 4:
print(USAGE)
sys.exit(1)

project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
input_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(project_root, 'src', 'images', 'Logo256.png')
size = int(sys.argv[2]) if len(sys.argv) > 2 else 256
if size % 8 != 0:
sys.exit('error: size must be a multiple of 8 (the mask packs 8 pixels per byte)')

levels, opacity = build_levels_and_opacity(input_path, size)
name = f'Logo{size}'
output_path = os.path.join(project_root, 'src', 'images', f'{name}.h')
with open(output_path, 'w') as f:
f.write('#pragma once\n#include <cstdint>\n\n')
f.write('// Generated by scripts/convert_logo.py -- do not edit by hand.\n')
f.write(f'// Image dimensions: {size}x{size}. Render with GfxRenderer::drawImage2Bit,\n')
f.write('// which decodes the BW base or a differential grayscale plane per the\n')
f.write('// current render mode and skips pixels the mask marks transparent.\n')
emit_array(f, name, 'Packed 2-bit tones: 4 pixels/byte, MSB pair first, 0=black .. 3=white',
pack_2bit(levels, size))
f.write('\n')
emit_array(f, f'{name}Mask', 'Packed 1-bit opacity: 8 pixels/byte, MSB first, 1 = opaque',
pack_1bit(opacity, size))
print(f'Wrote {output_path} ({size * size // 4} + {size * size // 8} bytes)')

if len(sys.argv) > 3:
write_preview(levels, opacity, size, sys.argv[3])


if __name__ == '__main__':
main()
Loading
Loading