Files
beeos/tools/convert_icons_for_lvgl.py
T

365 lines
13 KiB
Python

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 <stdint.h>",
"",
"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 <lvgl.h>",
"",
]
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 <lvgl.h>",
"",
]
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()