feat: add GIF animation packer #1
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -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/
|
||||
101
AGENTS.md
101
AGENTS.md
@@ -0,0 +1,101 @@
|
||||
# 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`.
|
||||
- Version numbers must follow Semantic Versioning 2.0.0.
|
||||
- 如果需要新增示例输入素材,确认文件大小合理,并在 `README.md` 中说明用途。
|
||||
- 如果修改默认输出位置、颜色格式或压缩格式,请同步检查 `.gitignore` 和文档。
|
||||
|
||||
## 验证建议
|
||||
|
||||
没有完整 LVGL 环境时,至少运行:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile EmbeddedAnimPacker.py
|
||||
python3 EmbeddedAnimPacker.py --help
|
||||
```
|
||||
|
||||
有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。
|
||||
|
||||
174
EmbeddedAnimPacker.py
Normal file
174
EmbeddedAnimPacker.py
Normal file
@@ -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
|
||||
42
README.md
42
README.md
@@ -0,0 +1,42 @@
|
||||
# EmbeddedAnimPacker
|
||||
|
||||
把 GIF 动画拆成 PNG 帧,并逐帧调用 LVGL 的 `LVGLImage.py` 生成 BIN 文件。
|
||||
|
||||
## 使用
|
||||
|
||||
建议先创建并启用 Python 虚拟环境:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
```
|
||||
|
||||
然后安装 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
|
||||
```
|
||||
|
||||
BIN
default.gif
Normal file
BIN
default.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Pillow
|
||||
Reference in New Issue
Block a user