from __future__ import annotations """BeeOS icon asset conversion tool. Converts the curated BeeOS/Lucide PNG icon subset into firmware-friendly formats. Outputs: - JSON monochrome bitmap map - generic packed 1-bit C buffers - LVGL v8 image descriptors - LVGL v9 image descriptors - preview sheets The LVGL descriptor exports are intentionally generated as separate v8/v9 files because LVGL image descriptor APIs changed between major versions. """ import argparse import json from pathlib import Path from PIL import Image, ImageDraw DEFAULT_SIZES = [18, 24, 34, 48] def load_icon_ids(svg_manifest_path: Path) -> list[str]: data = json.loads(svg_manifest_path.read_text(encoding="utf-8")) return [item["id"] for item in data.get("icons", [])] def image_to_mono_rows(path: Path, threshold: int = 24) -> tuple[int, int, list[str], list[int], int]: img = Image.open(path).convert("RGBA") width, height = img.size stride = (width + 7) // 8 rows: list[str] = [] packed: list[int] = [] for y in range(height): bits = [] byte = 0 count = 0 for x in range(width): r, g, b, a = img.getpixel((x, y)) on = a > threshold and (r + g + b) > threshold bits.append("1" if on else "0") byte = (byte << 1) | (1 if on else 0) count += 1 if count == 8: packed.append(byte) byte = 0 count = 0 if count: byte = byte << (8 - count) packed.append(byte) rows.append("".join(bits)) return width, height, rows, packed, stride def c_array_name(icon_id: str, size: int) -> str: clean = icon_id.replace("-", "_").replace(".", "_") return f"beeos_icon_{clean}_{size}" def export_json(icon_ids: list[str], png_dir: Path, output_dir: Path, size: int) -> Path: icons = {} for icon_id in icon_ids: path = png_dir / str(size) / f"{icon_id}.png" if not path.exists(): continue width, height, rows, packed, stride = image_to_mono_rows(path) icons[icon_id] = { "width": width, "height": height, "stride": stride, "rows": rows, "packed_bytes": packed, } output_dir.mkdir(parents=True, exist_ok=True) out = output_dir / f"beeos_icons_{size}.json" out.write_text(json.dumps({"size": size, "icons": icons}, indent=2), encoding="utf-8") return out def collect_icon_data(icon_ids: list[str], png_dir: Path, size: int) -> list[dict]: rows = [] for icon_id in icon_ids: path = png_dir / str(size) / f"{icon_id}.png" if not path.exists(): continue width, height, _rows, packed, stride = image_to_mono_rows(path) name = c_array_name(icon_id, size) rows.append( { "id": icon_id, "name": name, "width": width, "height": height, "stride": stride, "data": packed, } ) return rows def export_generic_c(icon_rows: list[dict], output_dir: Path, size: int) -> tuple[Path, Path]: output_dir.mkdir(parents=True, exist_ok=True) header = output_dir / f"beeos_icons_{size}.h" source = output_dir / f"beeos_icons_{size}.c" header_lines = [ "/* Auto-generated BeeOS icon declarations. */", "#pragma once", "", "#include ", "", "typedef struct BeeOSIconAsset {", " const char *name;", " uint16_t width;", " uint16_t height;", " uint16_t stride;", " const uint8_t *data;", " uint32_t data_len;", "} BeeOSIconAsset;", "", ] source_lines = [ "/* Auto-generated BeeOS 1-bit icon data. */", "", f'#include "beeos_icons_{size}.h"', "", ] for row in icon_rows: header_lines.append(f"extern const uint8_t {row['name']}_data[{len(row['data'])}];") source_lines.append(f"const uint8_t {row['name']}_data[{len(row['data'])}] = {{") for i in range(0, len(row["data"]), 12): chunk = row["data"][i : i + 12] source_lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",") source_lines.append("};") source_lines.append("") header_lines.extend( [ "", f"extern const BeeOSIconAsset beeos_icons_{size}[];", f"extern const uint32_t beeos_icons_{size}_count;", "", ] ) source_lines.append(f"const BeeOSIconAsset beeos_icons_{size}[] = {{") for row in icon_rows: source_lines.append( f' {{"{row["id"]}", {row["width"]}, {row["height"]}, {row["stride"]}, {row["name"]}_data, {len(row["data"])}}},' ) source_lines.append("};") source_lines.append("") source_lines.append(f"const uint32_t beeos_icons_{size}_count = {len(icon_rows)};") source_lines.append("") header.write_text("\\n".join(header_lines), encoding="utf-8") source.write_text("\\n".join(source_lines), encoding="utf-8") return header, source def export_lvgl_v8(icon_rows: list[dict], output_dir: Path, size: int) -> tuple[Path, Path]: output_dir.mkdir(parents=True, exist_ok=True) header = output_dir / f"beeos_icons_{size}_lvgl_v8.h" source = output_dir / f"beeos_icons_{size}_lvgl_v8.c" header_lines = [ "/* Auto-generated BeeOS LVGL v8 icon descriptors. */", "#pragma once", "", "#include ", "", ] source_lines = [ "/* Auto-generated BeeOS LVGL v8 icon descriptors.", " Uses LV_IMG_CF_ALPHA_1BIT for monochrome/alpha icon masks.", " Style code should recolor these with image recolor/opacity if needed. */", "", f'#include "beeos_icons_{size}_lvgl_v8.h"', "", ] for row in icon_rows: dsc_name = f"{row['name']}_dsc" data_name = f"{row['name']}_data" header_lines.append(f"LV_IMG_DECLARE({dsc_name});") source_lines.append(f"static const uint8_t {data_name}[] = {{") for i in range(0, len(row["data"]), 12): chunk = row["data"][i : i + 12] source_lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",") source_lines.append("};") source_lines.append("") source_lines.append(f"const lv_img_dsc_t {dsc_name} = {{") source_lines.append(" .header.always_zero = 0,") source_lines.append(" .header.reserved = 0,") source_lines.append(" .header.cf = LV_IMG_CF_ALPHA_1BIT,") source_lines.append(f" .header.w = {row['width']},") source_lines.append(f" .header.h = {row['height']},") source_lines.append(f" .data_size = {len(row['data'])},") source_lines.append(f" .data = {data_name},") source_lines.append("};") source_lines.append("") header.write_text("\\n".join(header_lines), encoding="utf-8") source.write_text("\\n".join(source_lines), encoding="utf-8") return header, source def export_lvgl_v9(icon_rows: list[dict], output_dir: Path, size: int) -> tuple[Path, Path]: output_dir.mkdir(parents=True, exist_ok=True) header = output_dir / f"beeos_icons_{size}_lvgl_v9.h" source = output_dir / f"beeos_icons_{size}_lvgl_v9.c" header_lines = [ "/* Auto-generated BeeOS LVGL v9 icon descriptors. */", "#pragma once", "", "#include ", "", ] source_lines = [ "/* Auto-generated BeeOS LVGL v9 icon descriptors.", " Uses LV_COLOR_FORMAT_I1 for monochrome/alpha icon masks.", " Some LVGL v9 ports may need color-format adjustments depending on display setup. */", "", f'#include "beeos_icons_{size}_lvgl_v9.h"', "", ] for row in icon_rows: dsc_name = f"{row['name']}_dsc" data_name = f"{row['name']}_data" header_lines.append(f"extern const lv_image_dsc_t {dsc_name};") source_lines.append(f"static const uint8_t {data_name}[] = {{") for i in range(0, len(row["data"]), 12): chunk = row["data"][i : i + 12] source_lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",") source_lines.append("};") source_lines.append("") source_lines.append(f"const lv_image_dsc_t {dsc_name} = {{") source_lines.append(" .header.magic = LV_IMAGE_HEADER_MAGIC,") source_lines.append(" .header.cf = LV_COLOR_FORMAT_I1,") source_lines.append(" .header.flags = 0,") source_lines.append(f" .header.w = {row['width']},") source_lines.append(f" .header.h = {row['height']},") source_lines.append(f" .header.stride = {row['stride']},") source_lines.append(f" .data_size = {len(row['data'])},") source_lines.append(f" .data = {data_name},") source_lines.append("};") source_lines.append("") header.write_text("\\n".join(header_lines), encoding="utf-8") source.write_text("\\n".join(source_lines), encoding="utf-8") return header, source def export_preview(icon_ids: list[str], png_dir: Path, output_dir: Path, size: int) -> Path: cols = 5 cell_w = 96 cell_h = 84 rows = (len(icon_ids) + cols - 1) // cols sheet = Image.new("RGBA", (cols * cell_w, max(1, rows) * cell_h), (0, 0, 0, 255)) draw = ImageDraw.Draw(sheet) for idx, icon_id in enumerate(icon_ids): x = (idx % cols) * cell_w y = (idx // cols) * cell_h draw.rounded_rectangle((x + 18, y + 6, x + 78, y + 66), radius=18, fill=(20, 22, 28, 255), outline=(255, 204, 0, 255)) path = png_dir / str(size) / f"{icon_id}.png" if path.exists(): icon = Image.open(path).convert("RGBA") sheet.alpha_composite(icon, (x + (cell_w - icon.width) // 2, y + 18)) draw.text((x + 6, y + 68), icon_id[:14], fill=(220, 220, 220, 255)) output_dir.mkdir(parents=True, exist_ok=True) out = output_dir / f"beeos_icons_{size}_preview.png" sheet.save(out) return out def convert(project_root: Path, sizes: list[int]) -> dict: svg_manifest = project_root / "beeos" / "assets" / "icons" / "lucide" / "manifest.json" png_dir = project_root / "beeos" / "assets" / "icons" / "lucide_png" generated_root = project_root / "beeos" / "generated" / "icons" json_dir = generated_root / "bitmap_json" generic_dir = generated_root / "lvgl" v8_dir = generated_root / "lvgl_v8" v9_dir = generated_root / "lvgl_v9" preview_dir = generated_root / "previews" icon_ids = load_icon_ids(svg_manifest) result = { "icon_count": len(icon_ids), "sizes": {}, } for size in sizes: icon_rows = collect_icon_data(icon_ids, png_dir, size) json_out = export_json(icon_ids, png_dir, json_dir, size) generic_h, generic_c = export_generic_c(icon_rows, generic_dir, size) v8_h, v8_c = export_lvgl_v8(icon_rows, v8_dir, size) v9_h, v9_c = export_lvgl_v9(icon_rows, v9_dir, size) preview = export_preview(icon_ids, png_dir, preview_dir, size) result["sizes"][str(size)] = { "json": str(json_out.relative_to(project_root)), "generic_header": str(generic_h.relative_to(project_root)), "generic_source": str(generic_c.relative_to(project_root)), "lvgl_v8_header": str(v8_h.relative_to(project_root)), "lvgl_v8_source": str(v8_c.relative_to(project_root)), "lvgl_v9_header": str(v9_h.relative_to(project_root)), "lvgl_v9_source": str(v9_c.relative_to(project_root)), "preview": str(preview.relative_to(project_root)), } index_out = generated_root / "icon_build_index.json" index_out.parent.mkdir(parents=True, exist_ok=True) index_out.write_text(json.dumps(result, indent=2), encoding="utf-8") return result def main() -> None: parser = argparse.ArgumentParser(description="Convert BeeOS curated icons into firmware-friendly assets.") parser.add_argument("--project-root", default=".", help="BeeOS project root") parser.add_argument("--sizes", default="24,34", help="Comma-separated icon sizes to export") args = parser.parse_args() root = Path(args.project_root).resolve() sizes = [int(part.strip()) for part in args.sizes.split(",") if part.strip()] result = convert(root, sizes) print("BeeOS icon conversion complete.") print(f"Icons: {result['icon_count']}") for size, outputs in result["sizes"].items(): print(f"Size {size}:") for key, path in outputs.items(): print(f" {key}: {path}") if __name__ == "__main__": main()