From fb65367654625d8e25daf85ef61d8ed49e3ddc96 Mon Sep 17 00:00:00 2001 From: "Bairan.Zhang" Date: Wed, 1 Jul 2026 13:52:35 +0800 Subject: [PATCH] gif tool --- gif_to_eye_image.py | 573 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 gif_to_eye_image.py diff --git a/gif_to_eye_image.py b/gif_to_eye_image.py new file mode 100644 index 0000000..7456478 --- /dev/null +++ b/gif_to_eye_image.py @@ -0,0 +1,573 @@ +from pathlib import Path +import argparse +from itertools import groupby +import re +import python-rle + +from PIL import Image, ImageSequence + + +DEFAULT_INPUT = Path(r"D:\esp\Project\lcd\source\eye2.bmp") +DEFAULT_OUTPUT_DIR = Path(r"D:\esp\Project\lcd\lcd\main") +DEFAULT_WIDTH = 320 +DEFAULT_HEIGHT = 240 +DEFAULT_INCLUDE_FRAMES = "" +DEFAULT_RLE_ITEMS_PER_LINE = 50 +DEFAULT_FRAME_OUTPUT_DIR = Path(r"D:\esp\Project\lcd\source\frame") +DEFAULT_FRAME_FORMAT = "png" + + +PINYIN_MAP = { + "上": "shang", + "中": "zhong", + "签": "qian", + "五": "wu", + "行": "xing", + "前": "qian", + "置": "zhi", + "和": "he", + "平": "ping", + "土": "tu", + "小": "xiao", + "吉": "ji", + "木": "mu", + "鱼": "yu", + "水": "shui", + "火": "huo", + "补": "bu", + "运": "yun", + "金": "jin", + "东": "dong", + "西": "xi", + "南": "nan", + "北": "bei", + "半": "ban", + "段": "duan", + "转": "zhuan", + "圈": "quan", + "抽": "chou", + "帧": "zhen", + "断": "duan", + "网": "wang", + "更": "geng", + "新": "xin", + "有": "you", + "服": "fu", + "务": "wu", + "器": "qi", + "链": "lian", + "连": "lian", + "接": "jie", + "失": "shi", + "败": "bai", + "电": "dian", + "量": "liang", + "低": "di", + "眨": "zha", + "眼": "yan", + "动": "dong", + "图": "tu", + "招": "zhao", + "财": "cai", + "猫": "mao", + "素": "su", + "材": "cai", + "默": "mo", + "认": "ren", + "表": "biao", + "情": "qing", + "以": "yi", + "及": "ji", + "位": "wei", + "统": "tong", + "一": "yi", + "基": "ji", + "础": "chu", +} +def format_bytes(size): + units = ("B", "KB", "MB", "GB") + value = float(size) + unit_index = 0 + while value >= 1024 and unit_index < len(units) - 1: + value /= 1024 + unit_index += 1 + if unit_index == 0: + return "%d %s" % (size, units[unit_index]) + return "%.2f %s (%d B)" % (value, units[unit_index], size) + + +def image_data_size(image): + return image.width * image.height * len(image.getbands()) + + +def product_size_text(product): + if isinstance(product, Path): + return format_bytes(product.stat().st_size) if product.exists() else "0 B" + if isinstance(product, Image.Image): + return "%dx%d, %s" % (product.width, product.height, format_bytes(image_data_size(product))) + if isinstance(product, list): + item_size = 2 if all(isinstance(item, int) for item in product) else 4 + return "%d items, %s" % (len(product), format_bytes(len(product) * item_size)) + return str(product) + + +def print_product(label, product_type, product): + print("%s: type=%s, size=%s" % (label, product_type, product_size_text(product))) + +TARGET_CONFIG = { + "eye": { + "header": "eye_image.h", + "source": "eye_image.c", + "include": "eye_image.h", + "array_prefix": "eye_image_image", + "source_prefix": "eye_image_source", + "source_type": "eye_image_source_t", + "source_count_macro": "EYE_IMAGE_SOURCE_COUNT", + "table": "eye_image_sources", + }, + "icon": { + "header": "icon_image.h", + "source": "icon_image.c", + "include": "icon_image.h", + "array_prefix": "icon_image", + "source_prefix": "icon_image_source", + "source_type": "icon_image_source_t", + "source_count_macro": "ICON_IMAGE_SOURCE_COUNT", + "table": "icon_image_sources", + }, +} + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("input", nargs="?", default=str(DEFAULT_INPUT)) + parser.add_argument("--target", choices=("eye", "icon"), default="eye") + parser.add_argument("--out-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH) + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT) + parser.add_argument("--max-frames", type=int, default=0) + parser.add_argument("--step", type=int, default=1) + parser.add_argument("--include-frames", default=DEFAULT_INCLUDE_FRAMES) + parser.add_argument("--rle-items-per-line", type=int, default=DEFAULT_RLE_ITEMS_PER_LINE) + parser.add_argument("--frame-out-dir", default=str(DEFAULT_FRAME_OUTPUT_DIR)) + parser.add_argument("--frame-format", default=DEFAULT_FRAME_FORMAT, choices=("png", "bmp", "jpg", "jpeg")) + parser.add_argument("--frame-prefix", default="") + parser.add_argument("--no-save-frames", action="store_true") + parser.add_argument("--clear-frames", action="store_true") + parser.add_argument("--overwrite-image", action="store_true") + return parser.parse_args() + + +def parse_include_frames(text): + if text.strip() == "": + return None + + frames = set() + for item in text.split(","): + item = item.strip() + if item: + frames.add(int(item)) + + return frames + + +def is_cjk(char): + return "\u4e00" <= char <= "\u9fff" + + +def image_name_to_symbol_prefix(name): + parts = [] + last_was_separator = False + + for char in name: + if char.isascii() and (char.isalnum() or char == "_"): + parts.append(char) + last_was_separator = False + elif char in PINYIN_MAP: + parts.append(PINYIN_MAP[char]) + last_was_separator = False + elif is_cjk(char): + parts.append("u%04x" % ord(char)) + last_was_separator = False + else: + if parts and not last_was_separator: + parts.append("_") + last_was_separator = True + + symbol = "".join(parts).strip("_") + if symbol == "": + symbol = "image" + if symbol[0].isdigit(): + symbol = "image_" + symbol + return symbol + + +def fit_image(image, width, height): + frame = image.convert("RGBA") + frame.thumbnail((width, height), Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 255)) + x = (width - frame.width) // 2 + y = (height - frame.height) // 2 + canvas.alpha_composite(frame, (x, y)) + return canvas.convert("RGB") + + +def rgb_to_rgb565(red, green, blue): + return ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3) +def frame_to_rgb565(frame): + return [rgb_to_rgb565(red, green, blue) for red, green, blue in frame.getdata()] + + +def pixels_to_rle(pixels): + runs = [] + for color, group in groupby(pixels): + count = sum(1 for _ in group) + while count > 65535: + runs.append((color, 65535)) + count -= 65535 + runs.append((color, count)) + return runs + + +def frame_to_rgb888(frame): + return [(red << 16) | (green << 8) | blue for red, green, blue in frame.getdata()] + + +def format_array_items(items, items_per_line): + lines = [] + for index in range(0, len(items), items_per_line): + lines.append(" " + ", ".join(items[index:index + items_per_line])) + return "\n".join(lines) + + +def rgb888_product_text(label, frame, items_per_line=40): + values = frame_to_rgb888(frame) + items = ["0x%06X" % value for value in values] + return "%s[%d] = {\n%s\n}" % (label, len(values), format_array_items(items, items_per_line)) + + +def rgb565_product_text(label, pixels, items_per_line=40): + items = ["0x%04X" % value for value in pixels] + return "%s[%d] = {\n%s\n}" % (label, len(pixels), format_array_items(items, items_per_line)) + + +def rle_product_text(label, runs, items_per_line=24): + items = ["{0x%04X, %u}" % (color, count) for color, count in runs] + return "%s[%d] = {\n%s\n}" % (label, len(runs), format_array_items(items, items_per_line)) + + +def print_and_collect_product(text, products): + print(text) + products.append(text) + + + +def print_summary_size(label, size): + print("%s: %s" % (label, format_bytes(size))) + + +def summary_size_text(label, size): + return "%s: %s" % (label, format_bytes(size)) + + +def print_and_collect_line(text, lines): + print(text) + lines.append(text) + + +def summarize_frames(frames): + frame_count = len(frames) + rgb888_size = sum(image_data_size(frame) for _, frame, _ in frames) + rgb565_size = sum(len(pixels) * 2 for _, _, pixels in frames) + return frame_count, rgb888_size, rgb565_size + + +def summarize_preview_files(output_dir, frames, frame_format, prefix): + extension = "jpg" if frame_format == "jpeg" else frame_format + total_size = 0 + for source_index, _, _ in frames: + path = output_dir / f"{prefix}{source_index:04d}.{extension}" + if path.exists(): + total_size += path.stat().st_size + return total_size +def collect_frames(input_path, width, height, max_frames, step, include_frames): + source = Image.open(input_path) + frames = [] + + for index, raw_frame in enumerate(ImageSequence.Iterator(source)): + if index % step != 0: + continue + if include_frames is not None and index not in include_frames: + continue + frame = fit_image(raw_frame, width, height) + pixels = frame_to_rgb565(frame) + frames.append((index, frame, pixels)) + + if max_frames > 0 and len(frames) >= max_frames: + break + + if len(frames) == 0: + frame = fit_image(source, width, height) + pixels = frame_to_rgb565(frame) + frames.append((0, frame, pixels)) + + return frames + +def write_eye_header(output_dir, width, height, source_count): + _ = (width, height) + path = output_dir / "eye_image.h" + text = ( + "#ifndef EYE_IMAGE_H\n" + "#define EYE_IMAGE_H\n\n" + "#include \n\n" + f"#define EYE_IMAGE_SOURCE_COUNT {source_count}\n\n" + "typedef struct {\n" + " uint16_t color;\n" + " uint16_t count;\n" + "} rle_structure_t;\n\n" + "typedef struct {\n" + " const rle_structure_t *runs;\n" + " uint32_t run_count;\n" + "} eye_image_frame_t;\n\n" + "typedef struct {\n" + " const eye_image_frame_t *frames;\n" + " uint16_t frame_count;\n" + "} eye_image_source_t;\n\n" + "extern const eye_image_source_t eye_image_sources[EYE_IMAGE_SOURCE_COUNT];\n" + "#endif\n" + ) + path.write_text(text, encoding="ascii") +def write_icon_header(output_dir, width, height, source_count): + path = output_dir / "icon_image.h" + text = ( + "#ifndef ICON_IMAGE_H\n" + "#define ICON_IMAGE_H\n\n" + "#include \n\n" + "#include \"eye_image.h\"\n\n" + f"#define ICON_IMAGE_SOURCE_COUNT {source_count}\n\n" + "typedef struct {\n" + " const eye_image_frame_t *frames;\n" + " uint16_t frame_count;\n" + " uint16_t width;\n" + " uint16_t height;\n" + "} icon_image_source_t;\n\n" + "extern const icon_image_source_t icon_image_sources[ICON_IMAGE_SOURCE_COUNT];\n\n" + "#endif\n" + ) + path.write_text(text, encoding="ascii") +def write_target_header(output_dir, target, width, height, source_count): + if target == "eye": + write_eye_header(output_dir, width, height, source_count) + else: + write_icon_header(output_dir, width, height, source_count) + + +def source_table_marker(config): + return f"const {config['source_type']} {config['table']}[{config['source_count_macro']}]" + + +def split_existing_source(path, config): + if not path.exists(): + return f'#include "{config["include"]}"\n\n', [] + + text = path.read_text(encoding="ascii") + marker = source_table_marker(config) + table_pos = text.find(marker) + if table_pos < 0: + if not text.endswith("\n"): + text += "\n" + return text + "\n", [] + + body = text[:table_pos].rstrip() + "\n\n" + table_text = text[table_pos:] + entries = re.findall(r"\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*(\d+)(?:\s*,\s*(\d+)\s*,\s*(\d+))?\s*\}", table_text) + entries = [ + (name, int(count), int(width) if width else None, int(height) if height else None) + for name, count, width, height in entries + if name != "0" + ] + return body, entries + + +def make_unique_symbol(base_name, used_symbols): + symbol = base_name + suffix = 1 + + while symbol in used_symbols: + symbol = f"{base_name}_{suffix}" + suffix += 1 + + used_symbols.add(symbol) + return symbol + + +def write_rle_array(output, symbol, runs, items_per_line): + output.write("static const rle_structure_t %s[] = {\n" % symbol) + + for index in range(0, len(runs), items_per_line): + line_runs = runs[index:index + items_per_line] + items = ["{0x%04X, %u}" % (color, count) for color, count in line_runs] + output.write(" %s,\n" % ", ".join(items)) + + output.write("};\n\n") + + +def write_source(output_dir, target, frames, width, height, rle_items_per_line, overwrite_image, image_symbol_prefix): + config = TARGET_CONFIG[target] + path = output_dir / config["source"] + if overwrite_image: + existing_body = f'#include "{config["include"]}"\n\n' + source_entries = [] + else: + existing_body, source_entries = split_existing_source(path, config) + + used_symbols = set(re.findall(r"static const rle_structure_t\s+([A-Za-z_][A-Za-z0-9_]*)\[\]", existing_body)) + used_symbols.update(name for name, _, _, _ in source_entries) + source_entries = list(source_entries) + rle_frames = [] + rle_total_size = 0 + rle_sizes = {} + for source_index, _, pixels in frames: + runs = pixels_to_rle(pixels) + rle_size = len(runs) * 4 + rle_total_size += rle_size + rle_sizes[source_index] = rle_size + rle_frames.append((source_index, runs)) + source_name = make_unique_symbol(f"{image_symbol_prefix}_source", used_symbols) + max_frame_index = max(source_index for source_index, _ in rle_frames) + frame_entries = {source_index: None for source_index, _ in rle_frames} + + with path.open("w", encoding="ascii") as output: + output.write(existing_body) + for frame_number, (source_index, runs) in enumerate(rle_frames): + symbol = make_unique_symbol(f"{image_symbol_prefix}{frame_number}", used_symbols) + write_rle_array(output, symbol, runs, rle_items_per_line) + frame_entries[source_index] = (symbol, len(runs)) + + output.write("static const eye_image_frame_t %s[] = {\n" % source_name) + for frame_index in range(max_frame_index + 1): + entry = frame_entries.get(frame_index) + if entry is None: + output.write(" {0, 0},\n") + else: + symbol, run_count = entry + output.write(" {%s, %u},\n" % (symbol, run_count)) + output.write("};\n\n") + + source_entries.append((source_name, max_frame_index + 1, width, height)) + output.write("%s = {\n" % source_table_marker(config)) + for name, frame_count, source_width, source_height in source_entries: + if target == "icon": + output.write(" {%s, %u, %u, %u},\n" % (name, frame_count, source_width, source_height)) + else: + output.write(" {%s, %u},\n" % (name, frame_count)) + output.write("};\n") + return len(source_entries), rle_total_size, rle_sizes + + +def clear_old_frame_sources(output_dir): + for path in output_dir.glob("eye_image_*.c"): + if path.is_file(): + path.unlink() + + +def clear_old_saved_frames(output_dir, prefix, extension): + for path in output_dir.glob(f"{prefix}[0-9][0-9][0-9][0-9].{extension}"): + if path.is_file(): + path.unlink() + + +def save_preview_frame(frame, output_path, image_format): + if image_format in ("jpg", "jpeg"): + frame = frame.convert("RGB") + else: + frame = frame.convert("RGBA") + + frame.save(output_path) + + +def save_preview_frames(output_dir, frames, image_format, prefix, clear_frames): + output_dir.mkdir(parents=True, exist_ok=True) + + extension = "jpg" if image_format == "jpeg" else image_format + if clear_frames: + clear_old_saved_frames(output_dir, prefix, extension) + + for source_index, frame, _ in frames: + output_path = output_dir / f"{prefix}{source_index:04d}.{extension}" + save_preview_frame(frame, output_path, image_format) + + +def main(): + args = parse_args() + input_path = Path(args.input) + output_dir = Path(args.out_dir) + frame_output_dir = Path(args.frame_out_dir) + frame_format = "jpg" if args.frame_format == "jpeg" else args.frame_format + frame_prefix = args.frame_prefix if args.frame_prefix else input_path.stem + image_symbol_prefix = image_name_to_symbol_prefix(input_path.stem) + + if args.step < 1: + raise ValueError("--step must be greater than 0") + if args.rle_items_per_line < 1: + raise ValueError("--rle-items-per-line must be greater than 0") + if args.max_frames < 0: + raise ValueError("--max-frames must be greater than or equal to 0") + + output_dir.mkdir(parents=True, exist_ok=True) + include_frames = parse_include_frames(args.include_frames) + frames = collect_frames(input_path, args.width, args.height, args.max_frames, args.step, include_frames) + + config = TARGET_CONFIG[args.target] + existing_source_count = 0 + if not args.overwrite_image: + _, existing_sources = split_existing_source(output_dir / config["source"], config) + existing_source_count = len(existing_sources) + + total_source_count = existing_source_count + 1 + + clear_old_frame_sources(output_dir) + write_target_header(output_dir, args.target, args.width, args.height, total_source_count) + total_source_count, rle_total_size, rle_sizes = write_source(output_dir, args.target, frames, args.width, args.height, args.rle_items_per_line, args.overwrite_image, image_symbol_prefix) + write_target_header(output_dir, args.target, args.width, args.height, total_source_count) + if not args.no_save_frames: + save_preview_frames(frame_output_dir, frames, frame_format, frame_prefix, args.clear_frames) + + frame_count, rgb888_size, rgb565_size = summarize_frames(frames) + + product_texts = [] + print_and_collect_line("summary:", product_texts) + print_and_collect_line(summary_size_text("input_file", input_path.stat().st_size), product_texts) + print_and_collect_line("frames: %d" % frame_count, product_texts) + for source_index, frame, pixels in frames: + runs = pixels_to_rle(pixels) + print_and_collect_line( + "frame[%d]: rgb888=%s, rgb565=%s, rle=%s" % ( + source_index, + format_bytes(image_data_size(frame)), + format_bytes(len(pixels) * 2), + format_bytes(rle_sizes.get(source_index, 0)), + ), + product_texts, + ) + print_and_collect_product(rgb888_product_text("frame[%d].rgb888" % source_index, frame), product_texts) + print_and_collect_product(rgb565_product_text("frame[%d].rgb565" % source_index, pixels), product_texts) + print_and_collect_product(rle_product_text("frame[%d].rle" % source_index, runs), product_texts) + print_and_collect_line(summary_size_text("rgb888_total", rgb888_size), product_texts) + print_and_collect_line(summary_size_text("rgb565_total", rgb565_size), product_texts) + print_and_collect_line(summary_size_text("rle_total", rle_total_size), product_texts) + frame_output_dir.mkdir(parents=True, exist_ok=True) + product_output_path = frame_output_dir / ("%s_products.txt" % image_symbol_prefix) + product_output_path.write_text("\n".join(product_texts) + "\n", encoding="ascii") + print("products_file: %s" % product_output_path) + + +if __name__ == "__main__": + main() + + + + + +