Initial BeeOS simulator and asset pipeline
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user