365 lines
13 KiB
Python
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()
|