diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..184ead2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Virtual environments and local environment files +.venv/ +venv/ +env/ +.env +.env.* + +# Packaging and build output +build/ +dist/ +*.egg-info/ + +# Generated animation conversion output +frames/ +littlefs/anim/ + +# Local LVGL checkout used by the converter +lvgl/ + +# Editor and OS noise +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Local Codex/agent workspace metadata +.agents/ +.codex/ diff --git a/AGENTS.md b/AGENTS.md index e69de29..41947e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +本文件给在本仓库中工作的编码代理使用。修改代码前先阅读 `README.md` 和当前脚本实现,保持改动小而直接。 + +## 项目概览 + +`EmbeddedAnimPacker` 是一个 Python 命令行工具,用于: + +1. 读取输入 GIF。 +2. 使用 Pillow 拆分为逐帧 PNG。 +3. 调用本地 LVGL 仓库中的 `scripts/LVGLImage.py`。 +4. 生成适合放入 LittleFS 的 LVGL BIN 图片文件。 + +主入口是 `EmbeddedAnimPacker.py`。 + +## 重要文件 + +- `EmbeddedAnimPacker.py`: CLI 主程序。 +- `requirements.txt`: Python 运行依赖,目前只有 `Pillow`。 +- `README.md`: 面向用户的使用说明。 +- `default.gif`: 示例输入素材。 +- `frames/`: `--keep-frames` 生成的中间 PNG 帧,通常不要提交。 +- `littlefs/anim/`: 默认 BIN 输出目录,通常不要提交。 +- `lvgl/`: 本地 LVGL checkout,作为外部工具目录使用,通常不要提交。 + +## 本地环境 + +推荐使用虚拟环境: + +```bash +python3 -m venv .venv +. .venv/bin/activate +python3 -m pip install -r requirements.txt +``` + +运行完整转换需要本地存在 LVGL 仓库,并确保 `lvgl/scripts/LVGLImage.py` 可用。 + +## 常用命令 + +安装依赖: + +```bash +python3 -m pip install -r requirements.txt +``` + +运行转换: + +```bash +python3 EmbeddedAnimPacker.py default.gif +``` + +指定 LVGL 和输出目录: + +```bash +python3 EmbeddedAnimPacker.py default.gif \ + --lvgl-dir ./lvgl \ + --output-dir ./littlefs/anim \ + --cf RGB565 \ + --compress RLE +``` + +保留中间帧: + +```bash +python3 EmbeddedAnimPacker.py default.gif --keep-frames ./frames +``` + +只检查 CLI 参数和基础语法时,可运行: + +```bash +python3 -m py_compile EmbeddedAnimPacker.py +python3 EmbeddedAnimPacker.py --help +``` + +## 代码约定 + +- 使用 Python 标准库和 `pathlib.Path` 处理路径。 +- 保持 CLI 参数清晰,新增参数时同步更新 `README.md`。 +- 对用户输入、文件路径和外部工具缺失给出明确错误信息。 +- 调用外部命令时使用参数列表,不拼接 shell 字符串。 +- 默认行为应避免留下临时文件;只有用户传入 `--keep-frames` 时才保留中间 PNG。 +- 不要引入重量级依赖,除非能明显简化核心流程。 + +## 版本控制注意事项 + +- 不要提交 `.venv/`、`frames/`、`littlefs/anim/`、`lvgl/`、缓存目录或本地编辑器文件。 +- Commit messages must be written in English and follow Conventional Commits 1.0.0, for example `feat: add frame prefix option` or `fix: validate GIF input path`. +- 如果需要新增示例输入素材,确认文件大小合理,并在 `README.md` 中说明用途。 +- 如果修改默认输出位置、颜色格式或压缩格式,请同步检查 `.gitignore` 和文档。 + +## 验证建议 + +没有完整 LVGL 环境时,至少运行: + +```bash +python3 -m py_compile EmbeddedAnimPacker.py +python3 EmbeddedAnimPacker.py --help +``` + +有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。 diff --git a/EmbeddedAnimPacker.py b/EmbeddedAnimPacker.py new file mode 100644 index 0000000..847f140 --- /dev/null +++ b/EmbeddedAnimPacker.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + + +DEFAULT_LVGL_DIR = Path("lvgl") +DEFAULT_OUTPUT_DIR = Path("littlefs/anim") +DEFAULT_COLOR_FORMAT = "RGB565" +DEFAULT_COMPRESS = "RLE" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Extract frames from a GIF and convert them to LVGL BIN images." + ) + parser.add_argument("gif", type=Path, help="Input GIF file.") + parser.add_argument( + "--lvgl-dir", + type=Path, + default=DEFAULT_LVGL_DIR, + help=f"LVGL directory. Default: {DEFAULT_LVGL_DIR}", + ) + parser.add_argument( + "-o", + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help=f"Output directory for generated .bin files. Default: {DEFAULT_OUTPUT_DIR}", + ) + parser.add_argument( + "--cf", + "--color-format", + dest="color_format", + default=DEFAULT_COLOR_FORMAT, + help=f"LVGL color format. Default: {DEFAULT_COLOR_FORMAT}", + ) + parser.add_argument( + "--compress", + default=DEFAULT_COMPRESS, + choices=("RLE", "LZ4", "NONE"), + help=f"LVGL compression mode. Default: {DEFAULT_COMPRESS}", + ) + parser.add_argument( + "--prefix", + default=None, + help="Frame filename prefix. Default: input GIF stem.", + ) + parser.add_argument( + "--keep-frames", + type=Path, + default=None, + help="Keep extracted PNG frames in this directory instead of using a temp directory.", + ) + parser.add_argument( + "--python", + default=sys.executable, + help="Python executable used to run LVGLImage.py. Default: current interpreter.", + ) + return parser.parse_args() + + +def require_valid_input(gif_path: Path, lvgl_dir: Path) -> Path: + if not gif_path.is_file(): + raise FileNotFoundError(f"GIF file not found: {gif_path}") + + if gif_path.suffix.lower() != ".gif": + raise ValueError(f"Input must be a .gif file: {gif_path}") + + converter = lvgl_dir / "scripts" / "LVGLImage.py" + if not converter.is_file(): + raise FileNotFoundError(f"LVGL image converter not found: {converter}") + + return converter + + +def extract_gif_frames(gif_path: Path, output_dir: Path, prefix: str) -> list[Path]: + try: + from PIL import Image, ImageSequence + except ImportError as exc: + raise RuntimeError( + "Pillow is required to read GIF files. Install it with: " + "python3 -m pip install Pillow" + ) from exc + + output_dir.mkdir(parents=True, exist_ok=True) + frame_paths: list[Path] = [] + + with Image.open(gif_path) as gif: + for index, frame in enumerate(ImageSequence.Iterator(gif)): + frame_path = output_dir / f"{prefix}_{index:04d}.png" + frame.convert("RGBA").save(frame_path) + frame_paths.append(frame_path) + + if not frame_paths: + raise RuntimeError(f"No frames found in GIF: {gif_path}") + + return frame_paths + + +def convert_frame( + frame_path: Path, + converter: Path, + output_dir: Path, + color_format: str, + compress: str, + python_executable: str, +) -> None: + print("Converting:", frame_path) + subprocess.run( + [ + python_executable, + str(converter), + "--ofmt", + "BIN", + "--cf", + color_format, + "--compress", + compress, + "-o", + str(output_dir), + str(frame_path), + ], + check=True, + ) + + +def main() -> int: + args = parse_args() + prefix = args.prefix or args.gif.stem + converter = require_valid_input(args.gif, args.lvgl_dir) + + args.output_dir.mkdir(parents=True, exist_ok=True) + + if args.keep_frames: + frame_dir = args.keep_frames + frame_paths = extract_gif_frames(args.gif, frame_dir, prefix) + + for frame_path in frame_paths: + convert_frame( + frame_path, + converter, + args.output_dir, + args.color_format, + args.compress, + args.python, + ) + else: + with tempfile.TemporaryDirectory(prefix="embedded_anim_") as temp_dir: + frame_paths = extract_gif_frames(args.gif, Path(temp_dir), prefix) + + for frame_path in frame_paths: + convert_frame( + frame_path, + converter, + args.output_dir, + args.color_format, + args.compress, + args.python, + ) + + print(f"Done. Converted {len(frame_paths)} frame(s) to {args.output_dir}.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/README.md b/README.md index e69de29..77212a1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,35 @@ +# EmbeddedAnimPacker + +把 GIF 动画拆成 PNG 帧,并逐帧调用 LVGL 的 `LVGLImage.py` 生成 BIN 文件。 + +## 使用 + +先安装 GIF 读取依赖: + +```bash +python3 -m pip install -r requirements.txt +``` + +然后传入 GIF 文件: + +```bash +python3 EmbeddedAnimPacker.py boot.gif +``` + +默认会调用 `lvgl/scripts/LVGLImage.py`,输出到 `littlefs/anim`。 + +常用参数: + +```bash +python3 EmbeddedAnimPacker.py boot.gif \ + --lvgl-dir ./lvgl \ + --output-dir ./littlefs/anim \ + --cf RGB565 \ + --compress RLE +``` + +保留中间 PNG 帧: + +```bash +python3 EmbeddedAnimPacker.py boot.gif --keep-frames ./frames +``` diff --git a/default.gif b/default.gif new file mode 100644 index 0000000..e86dd9b Binary files /dev/null and b/default.gif differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e2fba5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Pillow