feat(epaper): 4-level greyscale rendering in SSD168xDecoder
Panels that upload a custom waveform LUT (0x32) AND write both RAM planes (0x24 + 0x26) in a frame drive 4-level greyscale: each pixel is 2 bits (one per plane) composited by the custom LUT into 4 grey levels. Auto-detect this from the command stream (custom LUT + red-plane written; never key off a panel name) and expose an optional Frame.grey (0..3) alongside the existing 1-bit `pixels` (kept as a level>=2 -> white projection so 1-bit and BWR consumers - EPaperPart, GxEPD2 - are byte-identical). composeFrame builds a parallel grey plane and rotates it through the same transpose as the palette plane. The combo (bw<<1 | red) -> level map is [3,2,1,0] (white/light/dark/black): bw is the dominant luminance bit (bw=0 -> light pair, bw=1 -> dark pair), red refines one step within each half; verified against the captured menu's plane populations. Exported DEFAULT_GREY_COMBO_TO_LEVEL + an optional greyComboToLevel option (tunes ordering only, never forces greyscale on). Standard SSD1680 paths (raw badge examples, GxEPD2 full refresh) load the OTP LUT, never send 0x32, never write 0x26 -> unchanged. 23 unit tests pass (ssd168x-decoder + epaper-part-integration, incl. new greyscale + no-regression cases).
This commit is contained in:
parent
a77ef68456
commit
6442217893
|
|
@ -24,6 +24,7 @@ import {
|
|||
CMD_SET_RAMY_COUNTER,
|
||||
CMD_WRITE_BLACK_VRAM,
|
||||
CMD_WRITE_RED_VRAM,
|
||||
CMD_WRITE_LUT,
|
||||
CMD_MASTER_ACTIVATION,
|
||||
CMD_DEEP_SLEEP,
|
||||
} from '../simulation/displays/SSD168xDecoder';
|
||||
|
|
@ -322,6 +323,90 @@ describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('SSD168xDecoder — 4-level greyscale (custom LUT + both planes)', () => {
|
||||
// A host that uploads its own waveform LUT (0x32) AND writes BOTH RAM planes
|
||||
// (0x24 + 0x26) is driving a 2-bits-per-pixel 4-level greyscale render. The
|
||||
// decoder must auto-detect this from the stream (never a panel name) and
|
||||
// expose a `grey` channel of levels 0..3, with `pixels` a 1-bit projection.
|
||||
it('composes 4 grey levels from the two bit-planes', () => {
|
||||
const seen: Frame[] = [];
|
||||
const d = new SSD168xDecoder({
|
||||
width: 8,
|
||||
height: 1,
|
||||
palette: 'bw',
|
||||
onFlush: (f) => seen.push(f),
|
||||
});
|
||||
feedAll(
|
||||
d,
|
||||
cmd(CMD_WRITE_LUT), data(0x00, 0x11, 0x22), // custom waveform LUT
|
||||
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
|
||||
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x00),
|
||||
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x00, 0x00),
|
||||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||||
// bwBit sequence 0000 1111 -> 0x0F ; redBit sequence 0011 0011 -> 0x33.
|
||||
// Per-pixel combo (bwBit<<1|redBit): 0,0,1,1,2,2,3,3.
|
||||
cmd(CMD_WRITE_BLACK_VRAM), data(0x0f),
|
||||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||||
cmd(CMD_WRITE_RED_VRAM), data(0x33),
|
||||
cmd(CMD_MASTER_ACTIVATION),
|
||||
);
|
||||
expect(seen.length).toBe(1);
|
||||
const frame = seen[0];
|
||||
expect(frame.grey).toBeDefined();
|
||||
// combo 0->3(white) 1->2(light) 2->1(dark) 3->0(black)
|
||||
expect(Array.from(frame.grey!)).toEqual([3, 3, 2, 2, 1, 1, 0, 0]);
|
||||
// 1-bit projection: light levels (>=2) white, dark levels black.
|
||||
expect(Array.from(frame.pixels)).toEqual([1, 1, 1, 1, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('does NOT engage greyscale for a standard 1-bit stream (no 0x32, no 0x26)', () => {
|
||||
const seen: Frame[] = [];
|
||||
const d = new SSD168xDecoder({
|
||||
width: 200,
|
||||
height: 200,
|
||||
onFlush: (f) => seen.push(f),
|
||||
});
|
||||
gxepd2Init154(d);
|
||||
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
|
||||
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
|
||||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x7f));
|
||||
feedAll(d, cmd(CMD_DISP_UPDATE_CTRL_2), data(0xf7), cmd(CMD_MASTER_ACTIVATION));
|
||||
expect(seen.length).toBe(1);
|
||||
// No custom LUT and no red plane -> no greyscale channel, unchanged pixels.
|
||||
expect(seen[0].grey).toBeUndefined();
|
||||
expect(seen[0].pixels[0]).toBe(0);
|
||||
expect(seen[0].pixels[1]).toBe(1);
|
||||
});
|
||||
|
||||
it('a custom LUT alone (no 0x26) stays the legacy 1-bit path (no grey)', () => {
|
||||
const seen: Frame[] = [];
|
||||
const d = new SSD168xDecoder({
|
||||
width: 8,
|
||||
height: 1,
|
||||
onFlush: (f) => seen.push(f),
|
||||
});
|
||||
feedAll(
|
||||
d,
|
||||
cmd(CMD_WRITE_LUT), data(0x01),
|
||||
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
|
||||
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x00),
|
||||
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x00, 0x00),
|
||||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||||
cmd(CMD_WRITE_BLACK_VRAM), data(0xf0),
|
||||
cmd(CMD_MASTER_ACTIVATION),
|
||||
);
|
||||
// customBwLut inverts polarity (bit1=black) but with no red plane written
|
||||
// it must NOT become greyscale.
|
||||
expect(seen[0].grey).toBeUndefined();
|
||||
// bwInvert (custom LUT): white where 0x24 bit==0. 0xF0 (1111 0000) ->
|
||||
// first 4 px black, last 4 white.
|
||||
expect(Array.from(seen[0].pixels)).toEqual([0, 0, 0, 0, 1, 1, 1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSD168xDecoder — larger panel sizes', () => {
|
||||
// Use Uint8Array.every() (single hot loop, one assertion) rather than per-pixel
|
||||
// expect() — at 400×300 = 120k pixels Vitest's per-call overhead dominates.
|
||||
|
|
|
|||
|
|
@ -49,10 +49,46 @@ export type EPaperPalette = 0 | 1 | 2;
|
|||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
/** width*height palette indices. */
|
||||
/**
|
||||
* width*height palette indices (0 = black, 1 = white, 2 = red). Always
|
||||
* present. For a 4-level greyscale frame this is a 1-bit projection of
|
||||
* `grey` (level 0/1 -> black, level 2/3 -> white) so 1-bit/BWR consumers
|
||||
* keep working unchanged.
|
||||
*/
|
||||
pixels: Uint8Array;
|
||||
/**
|
||||
* OPTIONAL 4-level greyscale channel. Present ONLY when the stream was
|
||||
* detected as 2-bits-per-pixel greyscale (a custom waveform LUT via 0x32
|
||||
* AND both RAM planes written): width*height values 0..3 where 0 = black,
|
||||
* 3 = white, 1/2 = the two intermediate greys. Absent for every 1-bit /
|
||||
* tri-colour panel, so consumers that only read `pixels` are unaffected.
|
||||
*/
|
||||
grey?: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the 2-bit plane combo -> grey level 0..3 for greyscale mode. The
|
||||
* combo index is (bwBit << 1) | redBit, where bwBit is the 0x24 (WRITE_RAM_BW)
|
||||
* plane bit and redBit is the 0x26 (WRITE_RAM_RED) plane bit at that pixel.
|
||||
* Level 0 = black, 3 = white.
|
||||
*
|
||||
* The bwBit (0x24) is the dominant luminance bit — bwBit=0 selects the LIGHT
|
||||
* pair {white, light-grey}, bwBit=1 the DARK pair {dark-grey, black}; this is
|
||||
* the same polarity as the custom-waveform 1-bit path (white where 0x24 bit=0),
|
||||
* so the 1-bit projection of a greyscale frame is byte-identical to the legacy
|
||||
* render. The redBit (0x26) refines within each half — set = one step darker.
|
||||
* Verified empirically against a captured SSD1680 2.7" greyscale (picovector,
|
||||
* antialiased) menu render: combo 0 (both clear) is the white page background,
|
||||
* and this ordering yields smooth icon greys rather than speckle. Overridable
|
||||
* via SSD168xDecoderOptions.greyComboToLevel for a LUT that reorders the pair.
|
||||
*/
|
||||
export const DEFAULT_GREY_COMBO_TO_LEVEL: Readonly<[number, number, number, number]> = [
|
||||
3, // combo 0: bw=0 red=0 -> white
|
||||
2, // combo 1: bw=0 red=1 -> light grey
|
||||
1, // combo 2: bw=1 red=0 -> dark grey
|
||||
0, // combo 3: bw=1 red=1 -> black
|
||||
];
|
||||
|
||||
export interface SSD168xDecoderOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -65,6 +101,12 @@ export interface SSD168xDecoderOptions {
|
|||
palette?: 'bw' | 'bwr' | 'acep';
|
||||
/** Fired on every 0x20 MASTER_ACTIVATION with the latched composed frame. */
|
||||
onFlush?: (frame: Frame) => void;
|
||||
/**
|
||||
* Optional override of the 2-bit-combo -> grey-level map used in greyscale
|
||||
* mode (see DEFAULT_GREY_COMBO_TO_LEVEL). Only tunes the level ordering; it
|
||||
* does NOT force greyscale on — that is auto-detected from the stream.
|
||||
*/
|
||||
greyComboToLevel?: Readonly<[number, number, number, number]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,6 +172,19 @@ export class SSD168xDecoder {
|
|||
*/
|
||||
private customBwLut = false;
|
||||
|
||||
/**
|
||||
* True once the host wrote the 0x26 (WRITE_RAM_RED) plane since the last
|
||||
* flush/reset. On a standard mono panel 0x26 is never sent; on a tri-colour
|
||||
* panel it is the additive red plane; combined with customBwLut (a custom
|
||||
* 0x32 waveform) it signals the SECOND greyscale bit-plane of a 2-bpp
|
||||
* 4-level greyscale render. Reset per frame so a later 1-bit-only refresh
|
||||
* (0x24 alone) is not mis-detected as greyscale.
|
||||
*/
|
||||
private redWritten = false;
|
||||
|
||||
/** combo (bwBit<<1|redBit) -> grey level 0..3, for greyscale compose. */
|
||||
private readonly greyMap: Readonly<[number, number, number, number]>;
|
||||
|
||||
/** Diagnostics: how many full refresh activations we've seen. */
|
||||
refreshedCount = 0;
|
||||
/** Diagnostics: opcodes the host emitted that aren't in our table. */
|
||||
|
|
@ -147,6 +202,7 @@ export class SSD168xDecoder {
|
|||
this.ramBpr = (longSide + 7) >> 3;
|
||||
this.ramRows = longSide;
|
||||
this.onFlush = opts.onFlush;
|
||||
this.greyMap = opts.greyComboToLevel ?? DEFAULT_GREY_COMBO_TO_LEVEL;
|
||||
|
||||
const size = this.ramBpr * this.ramRows;
|
||||
this.bwRam = new Uint8Array(size).fill(0xff); // default white
|
||||
|
|
@ -181,6 +237,7 @@ export class SSD168xDecoder {
|
|||
this.y = 0;
|
||||
this.entryMode = 0x03;
|
||||
this.customBwLut = false;
|
||||
this.redWritten = false;
|
||||
this.xrange = [0, ((this.width + 7) >> 3) - 1];
|
||||
this.yrange = [0, this.height - 1];
|
||||
this.winXSet = false;
|
||||
|
|
@ -216,10 +273,17 @@ export class SSD168xDecoder {
|
|||
const yTopIsHigh = (this.entryMode & 0x02) === 0;
|
||||
// Inverted B/W polarity when the host uploaded a custom waveform LUT (0x32).
|
||||
const bwInvert = this.customBwLut;
|
||||
// 4-level greyscale: a custom waveform LUT (0x32) AND both RAM planes
|
||||
// written this frame means each pixel is 2 bits (0x24 = one bit-plane,
|
||||
// 0x26 = the other), composited by the custom LUT into 4 grey levels.
|
||||
// Auto-detected purely from the stream — no panel name involved.
|
||||
const greyscale = this.customBwLut && this.redWritten;
|
||||
const nwBytes = Math.max(0, x1 - x0 + 1);
|
||||
const nw = nwBytes * 8; // native width (px)
|
||||
const nh = Math.max(0, y1 - y0 + 1); // native height (rows)
|
||||
const native = new Uint8Array(nw * nh);
|
||||
const native = new Uint8Array(nw * nh); // palette 0/1/2
|
||||
// Parallel greyscale plane (level 0..3), only allocated in greyscale mode.
|
||||
const nativeGrey = greyscale ? new Uint8Array(nw * nh) : null;
|
||||
for (let ny = 0; ny < nh; ny++) {
|
||||
const srcY = yTopIsHigh ? y1 - ny : y0 + ny;
|
||||
const row = srcY * this.ramBpr + x0;
|
||||
|
|
@ -232,6 +296,17 @@ export class SSD168xDecoder {
|
|||
const x = base + bit;
|
||||
if (x >= nw) break;
|
||||
const mask = 0x80 >> bit;
|
||||
if (greyscale) {
|
||||
// Two raw plane bits -> combo -> grey level via the custom-LUT map.
|
||||
const bBit = (bByte & mask) !== 0 ? 1 : 0;
|
||||
const rBit = (rByte & mask) !== 0 ? 1 : 0;
|
||||
const level = this.greyMap[(bBit << 1) | rBit];
|
||||
nativeGrey![outRow + x] = level;
|
||||
// 1-bit projection for consumers that only read `pixels`:
|
||||
// dark levels (0,1) -> black, light levels (2,3) -> white.
|
||||
native[outRow + x] = level >= 2 ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
const bwWhite = ((bByte & mask) !== 0) !== bwInvert;
|
||||
if (this.isBwr) {
|
||||
native[outRow + x] = (rByte & mask) !== 0 ? 2 : bwWhite ? 1 : 0;
|
||||
|
|
@ -247,48 +322,51 @@ export class SSD168xDecoder {
|
|||
}
|
||||
}
|
||||
|
||||
// Map native -> display. `nw` is byte-padded (nwBytes*8) so it can exceed
|
||||
// the real native width when that isn't a multiple of 8 (e.g. the 2.13"
|
||||
// panel is 122 px wide -> nw=128). Detect orientation by BYTE width and
|
||||
// crop the padding using the true native width.
|
||||
// Map a native-geometry array -> display geometry (handles the byte-padded
|
||||
// native width and the rotation-0 / rotation-1 transpose). Shared by the
|
||||
// palette plane and the greyscale plane so both rotate identically.
|
||||
const W = this.width;
|
||||
const H = this.height;
|
||||
const Wb = (W + 7) >> 3;
|
||||
const Hb = (H + 7) >> 3;
|
||||
let pixels: Uint8Array;
|
||||
const toDisplay = (src: Uint8Array, fill: number): Uint8Array => {
|
||||
if (nh === H && nwBytes === Wb) {
|
||||
// Non-transposed (rotation 0): native actual width = W.
|
||||
if (nw === W) {
|
||||
pixels = native;
|
||||
} else {
|
||||
pixels = new Uint8Array(W * H).fill(1);
|
||||
if (nw === W) return src;
|
||||
const out = new Uint8Array(W * H).fill(fill);
|
||||
for (let ny = 0; ny < H; ny++) {
|
||||
const s = ny * nw;
|
||||
const d = ny * W;
|
||||
for (let x = 0; x < W; x++) pixels[d + x] = native[s + x];
|
||||
}
|
||||
for (let x = 0; x < W; x++) out[d + x] = src[s + x];
|
||||
}
|
||||
return out;
|
||||
} else if (nh === W && nwBytes === Hb && nh) {
|
||||
// Transposed (rotation 1): native actual width = H. Inverse of
|
||||
// Adafruit_GFX rotation 1: native(x_raw,y_raw) -> display(xd=y_raw,
|
||||
// yd=Wn-1-x_raw), Wn = true native width = H.
|
||||
pixels = new Uint8Array(W * H).fill(1);
|
||||
const out = new Uint8Array(W * H).fill(fill);
|
||||
const Wn = H;
|
||||
for (let ny = 0; ny < nh; ny++) {
|
||||
if (ny >= W) break;
|
||||
const src = ny * nw;
|
||||
for (let x = 0; x < Wn; x++) {
|
||||
pixels[(Wn - 1 - x) * W + ny] = native[src + x];
|
||||
const s = ny * nw;
|
||||
for (let x = 0; x < Wn; x++) out[(Wn - 1 - x) * W + ny] = src[s + x];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
// Unexpected geometry — best-effort top-left copy onto white.
|
||||
pixels = new Uint8Array(W * H).fill(1);
|
||||
// Unexpected geometry — best-effort top-left copy onto the fill colour.
|
||||
const out = new Uint8Array(W * H).fill(fill);
|
||||
for (let ny = 0; ny < Math.min(nh, H); ny++) {
|
||||
const s = ny * nw;
|
||||
const d = ny * W;
|
||||
for (let x = 0; x < Math.min(nw, W); x++) pixels[d + x] = native[s + x];
|
||||
for (let x = 0; x < Math.min(nw, W); x++) out[d + x] = src[s + x];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const pixels = toDisplay(native, 1); // 1 = white padding
|
||||
if (nativeGrey) {
|
||||
const grey = toDisplay(nativeGrey, 3); // 3 = white padding
|
||||
return { width: W, height: H, pixels, grey };
|
||||
}
|
||||
return { width: W, height: H, pixels };
|
||||
}
|
||||
|
|
@ -307,9 +385,12 @@ export class SSD168xDecoder {
|
|||
this.refreshedCount += 1;
|
||||
const frame = this.composeFrame();
|
||||
this.onFlush?.(frame);
|
||||
// Start a fresh window union for the next frame's pages.
|
||||
// Start a fresh window union for the next frame's pages, and a fresh
|
||||
// red-plane-written flag so a subsequent 1-bit-only refresh (0x24 with
|
||||
// no 0x26) is not mis-detected as greyscale.
|
||||
this.winXSet = false;
|
||||
this.winYSet = false;
|
||||
this.redWritten = false;
|
||||
return;
|
||||
}
|
||||
// WRITE_BLACK/RED are recognized no-ops here; handleData() routes their
|
||||
|
|
@ -381,6 +462,7 @@ export class SSD168xDecoder {
|
|||
} else if (cmd === CMD_WRITE_BLACK_VRAM) {
|
||||
this.writeRamByte(this.bwRam, byte);
|
||||
} else if (cmd === CMD_WRITE_RED_VRAM) {
|
||||
this.redWritten = true;
|
||||
this.writeRamByte(this.redRam, byte);
|
||||
} else if (cmd === CMD_WRITE_LUT) {
|
||||
// The host uploaded its own B/W waveform LUT (write_luts()) rather than
|
||||
|
|
|
|||
Loading…
Reference in New Issue