Files
columba/scripts/convert_icon_to_framebuffer.py
torlando-tech d75bd2d77f feat: display Columba logo on RNode OLED via external framebuffer
Add support for displaying the Columba constellation logo on RNode's
OLED display when connected. The logo is sent via KISS protocol using
the external framebuffer commands (CMD_FB_EXT, CMD_FB_WRITE).

Changes:
- Add conversion script to render icon to 64x64 monochrome bitmap
- Add columba_logo.py with 512-byte framebuffer data
- Add framebuffer methods to ColumbaRNodeInterface
- Auto-display logo after successful RNode connection
- Enable by default via enable_framebuffer config option

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 21:58:48 -05:00

202 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""
Convert Columba constellation icon to RNode framebuffer format.
This script renders the constellation design (nodes and connecting lines)
from ic_launcher_foreground.xml to a 64x64 monochrome bitmap suitable
for RNode's external framebuffer display.
Output: python/columba_logo.py with columba_fb_data byte array (512 bytes)
"""
from PIL import Image, ImageDraw
import os
# Original coordinates from ic_launcher_foreground.xml
# Viewport: 512x512, with group transform: scale(0.65) pivot(256,256) translate(6,-10)
# Node centers (solid circles) - coordinates in original 512x512 space
NODES = [
{"center": (406.658, 182.257), "radius": 9}, # Node 1 (top right)
{"center": (356.658, 138.257), "radius": 10.837}, # Node 2 (upper right)
{"center": (270.658, 193.257), "radius": 6.5}, # Node 3 (center)
{"center": (214.658, 403.257), "radius": 6.5}, # Node 4 (bottom)
{"center": (93.506, 128.312), "radius": 5}, # Node 5 (top left)
{"center": (230.655, 178.527), "radius": 5.34}, # Node 6 (left of center)
]
# Connecting lines (stroke width 6 in original)
LINES = [
((93.53, 128.431), (271.29, 193.077)), # Node 5 to Node 3
((270.75, 193.487), (216.617, 402.841)), # Node 3 to Node 4
((270.923, 193.326), (356.069, 139.108)), # Node 3 to Node 2
((405.781, 182.043), (356.85, 138.219)), # Node 1 to Node 2
]
# Transform parameters from the drawable
SCALE = 0.65
PIVOT = (256, 256)
TRANSLATE = (6, -10)
def get_bounding_box():
"""Calculate bounding box of all elements."""
all_points = []
for node in NODES:
cx, cy = node["center"]
r = node["radius"]
all_points.extend([(cx - r, cy - r), (cx + r, cy + r)])
for (x1, y1), (x2, y2) in LINES:
all_points.extend([(x1, y1), (x2, y2)])
xs = [p[0] for p in all_points]
ys = [p[1] for p in all_points]
return min(xs), min(ys), max(xs), max(ys)
def render_constellation(size=64, padding=4):
"""Render the constellation to an image.
Instead of using the original transform, we calculate a new transform
that centers and scales the design to fill the target size.
"""
# Create black background
img = Image.new('1', (size, size), 0) # 1-bit, black background
draw = ImageDraw.Draw(img)
# Get bounding box in original coordinates
min_x, min_y, max_x, max_y = get_bounding_box()
orig_width = max_x - min_x
orig_height = max_y - min_y
# Calculate scale to fit in target size with padding
available = size - 2 * padding
scale = min(available / orig_width, available / orig_height)
# Calculate offset to center
scaled_width = orig_width * scale
scaled_height = orig_height * scale
offset_x = (size - scaled_width) / 2 - min_x * scale
offset_y = (size - scaled_height) / 2 - min_y * scale
def transform(x, y):
return x * scale + offset_x, y * scale + offset_y
# Draw connecting lines first (so nodes appear on top)
line_width = max(1, int(2)) # Thin lines for 64px
for (x1, y1), (x2, y2) in LINES:
sx1, sy1 = transform(x1, y1)
sx2, sy2 = transform(x2, y2)
draw.line([(sx1, sy1), (sx2, sy2)], fill=1, width=line_width)
# Draw nodes (solid circles)
for node in NODES:
cx, cy = node["center"]
r = node["radius"]
scx, scy = transform(cx, cy)
# Scale radius, ensure minimum size of 1 pixel
sr = max(1.5, r * scale * 0.8)
# Draw filled ellipse
bbox = [scx - sr, scy - sr, scx + sr, scy + sr]
draw.ellipse(bbox, fill=1)
return img
def image_to_framebuffer(img):
"""Convert a 1-bit PIL image to RNode framebuffer format.
Format: 64 lines of 8 bytes each, MSB first, row-major.
Each byte represents 8 horizontal pixels.
"""
assert img.size == (64, 64), f"Expected 64x64, got {img.size}"
assert img.mode == '1', f"Expected 1-bit mode, got {img.mode}"
fb_data = []
for y in range(64):
for x_byte in range(8): # 8 bytes per row
byte_val = 0
for bit in range(8):
x = x_byte * 8 + bit
pixel = img.getpixel((x, y))
if pixel: # White pixel = 1
byte_val |= (1 << (7 - bit)) # MSB first
fb_data.append(byte_val)
return fb_data
def format_as_python(fb_data, var_name="columba_fb_data"):
"""Format the byte array as a Python module."""
lines = [
'"""',
'Columba logo framebuffer data for RNode external display.',
'',
'Format: 64x64 monochrome bitmap, 512 bytes',
' 8 pixels per byte, MSB first, row-major order',
'',
'Generated by scripts/convert_icon_to_framebuffer.py',
'"""',
'',
f'{var_name} = [',
]
# Format in rows of 16 bytes for readability (2 rows of display)
for i in range(0, len(fb_data), 16):
chunk = fb_data[i:i+16]
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f" {hex_str},")
lines.append(']')
lines.append('')
return '\n'.join(lines)
def main():
# Render the constellation
print("Rendering constellation icon at 64x64...")
img = render_constellation(64)
# Save preview (for debugging)
script_dir = os.path.dirname(os.path.abspath(__file__))
preview_path = os.path.join(script_dir, "columba_logo_preview.png")
# Scale up for better visibility
preview = img.resize((256, 256), Image.NEAREST)
preview.save(preview_path)
print(f"Preview saved to: {preview_path}")
# Convert to framebuffer format
print("Converting to framebuffer format...")
fb_data = image_to_framebuffer(img)
assert len(fb_data) == 512, f"Expected 512 bytes, got {len(fb_data)}"
# Generate Python module
python_code = format_as_python(fb_data)
# Write output
output_path = os.path.join(script_dir, "..", "python", "columba_logo.py")
output_path = os.path.normpath(output_path)
with open(output_path, 'w') as f:
f.write(python_code)
print(f"Output written to: {output_path}")
print(f"Total bytes: {len(fb_data)}")
# Print some stats
white_pixels = sum(bin(b).count('1') for b in fb_data)
total_pixels = 64 * 64
print(f"White pixels: {white_pixels}/{total_pixels} ({100*white_pixels/total_pixels:.1f}%)")
if __name__ == "__main__":
main()