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
55 changes: 15 additions & 40 deletions src/opendisplay/encoding/bitplanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,18 @@ def encode_bitplanes(
if image.mode != "P":
raise ValueError(f"Expected palette image, got {image.mode}")

pixels = np.array(image)
height, width = pixels.shape

# Calculate output size (1bpp, 8 pixels per byte)
bytes_per_row = (width + 7) // 8
plane1 = bytearray(bytes_per_row * height) # BW plane
plane2 = bytearray(bytes_per_row * height) # R/Y plane
pixels = np.asarray(image)

# Palette mapping:
# Index 0 = Black -> BW=0, R/Y=0
# Index 1 = White -> BW=1, R/Y=0
# Index 2 = Red/Yellow -> BW=0, R/Y=1
# packbits(axis=1) zero-pads each row to a byte boundary (8 pixels per byte,
# MSB first).
plane1 = np.packbits(pixels == 1, axis=1).tobytes() # BW plane
plane2 = np.packbits(pixels == 2, axis=1).tobytes() # R/Y plane

for y in range(height):
for x in range(width):
byte_idx = y * bytes_per_row + x // 8
bit_idx = 7 - (x % 8) # MSB first

palette_idx = pixels[y, x]

if palette_idx == 1:
# White - set BW plane
plane1[byte_idx] |= 1 << bit_idx
elif palette_idx == 2:
# Red/Yellow - set R/Y plane
plane2[byte_idx] |= 1 << bit_idx
# else: palette_idx == 0 (black) - both planes stay 0

return bytes(plane1), bytes(plane2)
return plane1, plane2


def encode_gray4_bitplanes(
Expand Down Expand Up @@ -93,20 +76,12 @@ def encode_gray4_bitplanes(
if image.mode != "P":
raise ValueError(f"Expected palette image, got {image.mode}")

pixels = np.array(image)
height, width = pixels.shape
bytes_per_row = (width + 7) // 8
plane0 = bytearray(bytes_per_row * height)
plane1 = bytearray(bytes_per_row * height)

for y in range(height):
for x in range(width):
byte_idx = y * bytes_per_row + x // 8
bit = 1 << (7 - (x % 8)) # MSB first
code = gray_codes[int(pixels[y, x]) & 0x03]
if code & 0x01:
plane0[byte_idx] |= bit
if code & 0x02:
plane1[byte_idx] |= bit

return bytes(plane0), bytes(plane1)
pixels = np.asarray(image)

# Map each pixel's dither level (index 0..3) through the panel's gray-code
# table to a 2-bit stored code, then split into two 1-bit planes.
codes = np.asarray(gray_codes, dtype=np.uint8)[pixels & 0x03]
plane0 = np.packbits(codes & 0x01, axis=1).tobytes()
plane1 = np.packbits(codes & 0x02, axis=1).tobytes()

return plane0, plane1
93 changes: 34 additions & 59 deletions src/opendisplay/encoding/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,11 @@ def encode_1bpp(image: Image.Image) -> bytes:
if image.mode != "P":
raise ValueError(f"Expected palette image, got {image.mode}")

pixels = np.array(image)
height, width = pixels.shape

# Calculate output size (round up to byte boundary)
bytes_per_row = (width + 7) // 8
output = bytearray(bytes_per_row * height)

for y in range(height):
for x in range(width):
byte_idx = y * bytes_per_row + x // 8
bit_idx = 7 - (x % 8) # MSB first

if pixels[y, x] > 0: # Non-zero palette index = white
output[byte_idx] |= 1 << bit_idx
pixels = np.asarray(image)

return bytes(output)
# Any non-zero palette index = white (bit set). packbits(axis=1) zero-pads
# each row to a byte boundary, matching the per-row layout above.
return np.packbits(pixels > 0, axis=1).tobytes()


def encode_2bpp(image: Image.Image, codes: tuple[int, int, int, int] | None = None) -> bytes:
Expand All @@ -151,25 +140,21 @@ def encode_2bpp(image: Image.Image, codes: tuple[int, int, int, int] | None = No
if image.mode != "P":
raise ValueError(f"Expected palette image, got {image.mode}")

pixels = np.array(image)
pixels = np.asarray(image)
height, width = pixels.shape

# Calculate output size (round up to 4-pixel boundary)
bytes_per_row = (width + 3) // 4
output = bytearray(bytes_per_row * height)

for y in range(height):
for x in range(width):
byte_idx = y * bytes_per_row + x // 4
pixel_in_byte = x % 4
bit_shift = (3 - pixel_in_byte) * 2 # MSB first

palette_idx = pixels[y, x] & 0x03 # 2-bit value
if codes is not None:
palette_idx = codes[palette_idx]
output[byte_idx] |= palette_idx << bit_shift

return bytes(output)
# Mask to 2 bits, remap through the panel code table if given, zero-pad the
# width to a multiple of 4 (matches the per-row byte boundary), then pack
# 4 pixels per byte MSB-first.
p = (pixels & 0x03).astype(np.uint8)
if codes is not None:
p = np.asarray(codes, dtype=np.uint8)[p]
pad = (-width) % 4
if pad:
p = np.pad(p, ((0, 0), (0, pad)))
p = p.reshape(height, -1, 4)
packed = (p[:, :, 0] << 6) | (p[:, :, 1] << 4) | (p[:, :, 2] << 2) | p[:, :, 3]
return packed.astype(np.uint8).tobytes()


def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes:
Expand All @@ -193,32 +178,22 @@ def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes:
if image.mode != "P":
raise ValueError(f"Expected palette image, got {image.mode}")

pixels = np.array(image)
pixels = np.asarray(image)
height, width = pixels.shape

# BWGBRY firmware color mapping (Spectra 6 display)
# Palette indices to firmware values: 0→0, 1→1, 2→2, 3→3, 4→5, 5→6
BWGBRY_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: 5, 5: 6}

# Calculate output size (round up to 2-pixel boundary)
bytes_per_row = (width + 1) // 2
output = bytearray(bytes_per_row * height)

for y in range(height):
for x in range(width):
byte_idx = y * bytes_per_row + x // 2

palette_idx = pixels[y, x] & 0x0F # 4-bit value

# Apply BWGBRY mapping if needed
if bwgbry_mapping:
palette_idx = BWGBRY_MAP.get(palette_idx, 0)

if x % 2 == 0:
# High nibble
output[byte_idx] |= palette_idx << 4
else:
# Low nibble
output[byte_idx] |= palette_idx

return bytes(output)
idx = (pixels & 0x0F).astype(np.uint8)

# BWGBRY firmware color mapping (Spectra 6 display): palette indices to
# firmware values 0→0, 1→1, 2→2, 3→3, 4→5, 5→6, everything else → 0
# (preserving the previous dict.get(..., 0) default).
if bwgbry_mapping:
lut = np.array([0, 1, 2, 3, 5, 6] + [0] * 10, dtype=np.uint8)
idx = lut[idx]

# Zero-pad width to an even number (matches the per-row byte boundary),
# then pack 2 pixels per byte, high nibble first.
if width & 1:
idx = np.pad(idx, ((0, 0), (0, 1)))
idx = idx.reshape(height, -1, 2)
packed = (idx[:, :, 0] << 4) | idx[:, :, 1]
return packed.astype(np.uint8).tobytes()