diff --git a/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md b/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md new file mode 100644 index 0000000..4181be1 --- /dev/null +++ b/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md @@ -0,0 +1,130 @@ +# GIF_REPORT — Phase 3 数字人 GIF 资源处理报告 + +> 阶段: `phase_03_gif_resources` +> 日期: 2026-05-13 +> 状态: ✅ **完成** + +## 1. 处理方式(与 PoC 阶段 hiyori_m05.gif 一致) + +3 个 hiyori 表情 GIF 经 `tools/prepare_hiyori_gifs.py` 处理,**等比例缩小到高 360px,不裁剪**: + +```bash +gifsicle --resize _x360 -O3 input.gif -o output.gif +``` + +- `--resize _x360`: 高度 = LCD 360px,宽度按原比例自动算 → 209px +- `-O3`: 优化压缩 +- **不加** `--lossy`(避免锯齿) +- **不加** `--colors`(保留 256 色,画质优先) +- **不裁剪**(保持源 GIF 完整人物) + +## 2. 处理结果对比 + +| GIF | 用途 | 源尺寸 | 源大小 | 输出尺寸 | 输出大小 | 节省 | +|-----|------|--------|--------|---------|----------|------| +| m03 | 负面/严肃 | 407×700 | 3,376 KB | **209×360** | **1,149 KB** | **66.0%** ⬇ | +| m06 | 默认/积极 | 407×700 | 1,303 KB | **209×360** | **442 KB** | **66.1%** ⬇ | +| m07 | 思考/疲倦 | 407×700 | 1,173 KB | **209×360** | **399 KB** | **66.0%** ⬇ | +| **合计** | — | — | **5,852 KB (5.7 MB)** | — | **1,990 KB (1.94 MB)** | **66.0%** ⬇ | + +PoC 阶段的 `hiyori_m05.gif` 也是 209×360(6.7MB → 2.3MB,节省 66.0%),处理参数完全一致。 + +## 3. 显示效果 + +LCD 360×360,GIF 209×360 居中显示: +- **垂直方向**: 360 = 360,完全充满 LCD 高度 +- **横向**: 209 < 360,左右各 75.5px 留边显示背景图 +- **角色比例**: 完整保留源 GIF 的 407:700 = 0.582 纵横比,人物细高自然 + +LVGL 代码(不变): +```c +bg_gif_demo_start( + "/spiflash/Background_360x360.jpg", + "/spiflash/hiyori_m06.gif" +); +// 内部 lv_obj_align(LV_ALIGN_CENTER, 0, 0) 自动居中 +``` + +## 4. 决策过程(避免后续重复犯错) + +Phase 3 初稿曾尝试**裁剪到 240×320**(PIL 全帧 bbox 居中裁剪),用户烧录后反馈视觉感官差。 +原因分析: +- 240×320 的纵横比 0.75,源 407×700 的纵横比 0.583 +- 强制裁剪后角色被"横向压扁",与原 Live2D 细高人物比例不符 +- 视觉上看起来"角色变粗",违反 PoC 阶段已验证的良好效果 + +最终决策:**回归 PoC 等比例缩小方式**,处理标准已写入用户级 feedback memory(`feedback_hiyori_gif_processing.md`),后续除非用户主动修改,否则一律用本方式。 + +## 5. SPIFFS 容量使用 + +``` +SPIFFS 容量: 4.94 MB (0x4F0000) + +实际占用: +├── Background_360x360.jpg 20 KB +├── hiyori_m03.gif 1,149 KB +├── hiyori_m06.gif 442 KB +├── hiyori_m07.gif 399 KB +├── 02.jpg 20 KB (历史) +└── default.jpg 9 KB (历史) +合计: ~2.0 MB (40% 占用,~2.94 MB 余量) +``` + +`hiyori_m05.gif` (2.27 MB) **已删除**——被 m06/m07/m03 替代,文件历史保留在 git。 + +## 6. 烧录运行时验证 + +烧录后启动监控 18 秒(0 次重启): + +```log +✅ I (1099) BG_GIF: 背景图已解码: 360x360 (253.1 KB RGB565) +✅ I (1489) BG_GIF: GIF 已加载到 PSRAM: /spiflash/hiyori_m06.gif (441.8 KB) +✅ I (1549) BG_GIF: ✓ 背景 + GIF 叠加显示启动 +✅ I (1699) Airhub1: 🤖 AI对话模式启动 +✅ I (3159) AudioCodec: Audio codec started ← 首次冷启动直接成功 +``` + +用户目视确认:**显示效果与 PoC 一致,角色细高比例自然**。 + +## 7. 默认表情切换 + +`main/dzbj/ai_chat_ui.c:234`: + +```c +esp_err_t bgret = bg_gif_demo_start( + "/spiflash/Background_360x360.jpg", + "/spiflash/hiyori_m06.gif"); // Phase 3: m06 默认(neutral/积极) +``` + +PoC 用 `hiyori_m05.gif` → Phase 3 切换到 m06(PoC 的 m05 已删除)。 + +## 8. 未在 Phase 3 验证的项目 + +| 项 | 推迟到 | +|----|--------| +| `m03` GIF 显示效果(负面情绪) | Phase 4(情绪映射时切换测试) | +| `m07` GIF 显示效果(睡眠) | Phase 4(情绪映射时切换测试) | +| GIF 切换内存泄漏(连续切 50 次 PSRAM 不减少) | Phase 4 实现 `bg_gif_demo_switch_gif()` 接口后 | +| 24h 长时间稳定性 | Phase 7 集成测试 | + +3 个 GIF 用同一脚本同一参数处理,单独测试 m03/m07 价值不高;Phase 4 实现切换接口后自然会触发。 + +## 9. 风险事项 + +| 风险 | 实际发生 | 处置 | +|------|---------|------| +| SPIFFS 装不下(旧版 5.4 MB > 4.94 MB) | ✅ 已发生(240×320 版本时) | 删除 m05.gif 释放 2.3 MB;切换到 209×360 后总占用降到 2 MB,不再紧张 | +| 默认表情失效(指向不存在的 m05) | ✅ 已发生 | 改 `ai_chat_ui.c` 默认指向 m06 | +| gifsicle brew link 失败 | ✅ 已规避 | 脚本用绝对路径 | +| 240×320 强制裁剪导致角色压扁 | ✅ 已发生 | 回归 PoC 的 `_x360` 等比例缩放方式,已写入 memory 防再犯 | + +## 10. Phase 3 验收结论 + +- ✅ Task 3.1: `tools/prepare_hiyori_gifs.py` (等比例缩小版本) 已提交 +- ✅ Task 3.2: 3 个 GIF (209×360) 生成 + 提交到 `spiffs_image/` +- ✅ Task 3.3: m06 烧录验证通过(首次启动 0 重启,用户目视确认) +- ✅ Task 3.4: 本报告生成 +- ✅ 移除 `hiyori_m05.gif`,默认表情更新为 m06 +- ✅ PoC 处理标准写入 user-scope memory(`feedback_hiyori_gif_processing.md`) + +**下一步**: Phase 4 — 实现"情绪标签 → 3 GIF 映射" + `bg_gif_demo_switch_gif()` 切换接口。 diff --git a/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/PLAN.md b/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/PLAN.md new file mode 100644 index 0000000..0117773 --- /dev/null +++ b/.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/PLAN.md @@ -0,0 +1,229 @@ +# Phase 3 PLAN — GIF 资源准备 + +> 里程碑: `digital_human_rtc` +> 阶段目标: 准备 m03/m06/m07 三个 hiyori 表情 GIF,裁剪到 240×320 显示上半身,居中显示在 360×360 LCD 屏幕上。 + +## 0. 调研结论 + +### 0.1 源 GIF 现状 + +所有 4 个 GIF 均为 **407×700 / 256 色 / 全帧透明**(Cubism Editor 直接导出): + +| GIF | 帧数 | 大小 | 状态 | +|-----|------|------|------| +| m05 | 136 | 6.7MB | 当前 PoC 使用中 | +| m03 | 66 | 3.3MB | **Phase 3 待处理(负面/严肃)** | +| m06 | 25 | 1.3MB | **Phase 3 待处理(默认/积极)** | +| m07 | 22 | 1.1MB | **Phase 3 待处理(思考/疲倦)** | + +### 0.2 工具状态 + +- **gifsicle 1.96**: 已安装在 brew cellar,**未 link** 到 `/opt/homebrew/bin/` + - 绝对路径: `/opt/homebrew/var/homebrew/tmp/.cellar/gifsicle/1.96/bin/gifsicle` + - 脚本中直接用绝对路径(不依赖 brew link) +- **PIL 12.2.0**: 可用 + +### 0.3 目标尺寸 + +- LCD: 360×360 +- GIF: 240×320 → 居中后留边: + - 左右各 60px (`(360-240)/2`) + - 上下各 20px (`(360-320)/2`) +- 背景图 360×360 占满屏幕,GIF 在中央显示 + +### 0.4 容量预算(vs PARTITION_REPORT.md 第 6 节) + +裁剪到 240×320 后像素数量是 407×700 的 27%,预期文件大小同比例缩小: + +| GIF | 原始 | 裁剪+优化后预期 | +|-----|------|---------------| +| m03 | 3.3MB | ~900KB | +| m06 | 1.3MB | ~350KB | +| m07 | 1.1MB | ~300KB | +| **合计** | **5.7MB** | **~1.55MB** | + +加上背景图 20KB,总 SPIFFS 占用约 **1.6MB**,远小于 SPIFFS 4.9MB 容量。 + +## 1. 任务清单 + +### Task 3.1: 写 Python 处理脚本 + +**文件**: `tools/prepare_hiyori_gifs.py`(新建) + +**逻辑**: +1. 用 PIL `ImageSequence.Iterator` 遍历所有帧 +2. 对每帧 `.convert('RGBA').getbbox()` 拿到非透明像素边界 +3. 计算**所有帧**的最大 bbox(避免某帧角色位置偏移导致裁剪偏右——CLAUDE.md 经验) +4. 从最大 bbox 计算 240×320 裁剪窗口: + - 宽度:使用 bbox 横向中心点 ± 120px + - 高度:从角色顶部(bbox top)向下 320px(自动裁掉膝盖以下) +5. 调用 gifsicle 做实际裁剪+重采样: + ```bash + gifsicle --crop X,Y+WxH --resize 240x320 -O3 input.gif -o output.gif + ``` + - **不加 `--lossy`**(会产生锯齿,CLAUDE.md 经验) + - **不加 `--colors`**(默认保留 256 色,画质优先) + - `-O3` 优化压缩 +6. 用 gifsicle `--info` 验证输出 +7. 输出路径:`spiffs_image/hiyori_m{03,06,07}.gif` + +**关键代码片段**: + +```python +#!/usr/bin/env python3 +"""Phase 3: 准备 hiyori 表情 GIF(裁剪 + 居中 + 优化)""" +import subprocess, sys +from pathlib import Path +from PIL import Image, ImageSequence + +GIFSICLE = "/opt/homebrew/var/homebrew/tmp/.cellar/gifsicle/1.96/bin/gifsicle" +OUT_W, OUT_H = 240, 320 + +def find_max_bbox(gif_path): + """遍历所有帧,找最大 bbox(兼容角色动作偏移)""" + img = Image.open(gif_path) + min_x, min_y, max_x, max_y = img.width, img.height, 0, 0 + for frame in ImageSequence.Iterator(img): + bbox = frame.convert('RGBA').getbbox() + if bbox: + min_x = min(min_x, bbox[0]) + min_y = min(min_y, bbox[1]) + max_x = max(max_x, bbox[2]) + max_y = max(max_y, bbox[3]) + return (min_x, min_y, max_x, max_y), img.width, img.height + +def compute_crop_box(bbox, src_w, src_h): + """从最大 bbox 计算 240×320 裁剪窗口""" + min_x, min_y, max_x, max_y = bbox + cx = (min_x + max_x) // 2 # 角色横向中心 + crop_x = max(0, min(cx - OUT_W // 2, src_w - OUT_W)) + crop_y = min_y # 从角色头顶开始 + if crop_y + OUT_H > src_h: + crop_y = src_h - OUT_H # 越界 fallback + return crop_x, crop_y + +def process_gif(src, dst): + bbox, sw, sh = find_max_bbox(src) + cx, cy = compute_crop_box(bbox, sw, sh) + print(f" 全帧 bbox: {bbox}, 源 {sw}x{sh}") + print(f" 裁剪起点: ({cx}, {cy}), 大小: {OUT_W}x{OUT_H}") + subprocess.run([ + GIFSICLE, + "--crop", f"{cx},{cy}+{OUT_W}x{OUT_H}", + "--resize", f"{OUT_W}x{OUT_H}", + "-O3", + src, "-o", dst + ], check=True) + # 验证 + info = subprocess.check_output([GIFSICLE, "--info", dst], text=True) + print(f" 输出大小: {Path(dst).stat().st_size / 1024:.1f} KB") + +if __name__ == "__main__": + src_base = Path("docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export") + dst_base = Path("spiffs_image") + for name in ["m03", "m06", "m07"]: + src = src_base / name / f"hiyori_{name}.gif" + dst = dst_base / f"hiyori_{name}.gif" + print(f"处理 {name}...") + process_gif(str(src), str(dst)) +``` + +**验证**: +- 脚本运行无错误 +- 生成 3 个 `spiffs_image/hiyori_m{03,06,07}.gif` + +**commit 消息**: `tools(phase03): 新增 hiyori GIF 处理脚本(PIL bbox + gifsicle 裁剪)` + +--- + +### Task 3.2: 执行脚本生成 3 个 GIF + 验证 + +**步骤**: +```bash +cd /Users/rdzleo/Desktop/Baji_Rtc_Toy +python3 tools/prepare_hiyori_gifs.py +ls -la spiffs_image/hiyori_*.gif +``` + +**验证标准**: +- ✅ 3 个 GIF 都存在 +- ✅ 每个 ≤ 1.5MB +- ✅ 三个合计 ≤ 3MB(SPIFFS 容量充足) +- ✅ `gifsicle --info` 显示 240×320 + transparent + 256 色 + +**commit 消息**: `feat(assets): Phase 3 hiyori 三表情 GIF(m03/m06/m07,240x320 居中裁剪)` + +--- + +### Task 3.3: 烧录验证(逐个 GIF) + +**目标**: 验证三个新 GIF 在设备上的显示效果(位置、画质、动画流畅度)。 + +**策略**: 临时修改 `main/dzbj/ai_chat_ui.c` 中的 `USE_BG_GIF_POC` 调用,让它依次加载 m03 / m06 / m07 测试,每次烧录 + 监控 ~10 秒。 + +**步骤(每个 GIF 重复一次)**: + +1. 临时改 `bg_gif_demo_start()` 调用参数为目标 GIF 路径 +2. `idf.py build`(增量,快) +3. `idf.py -p /dev/cu.usbmodem834401 flash` +4. Python 串口监控 10 秒,看是否: + - 无 abort / 重启循环 + - `BG_GIF` 日志正确加载(无 LZW 解码错误) + - PSRAM 余量充足 + +**验证标准(每个 GIF)**: +- ✅ 设备烧录后无重启循环(仅冷启动 codec 1 次失败属 Phase 0 已知问题) +- ✅ GIF 加载日志显示正确大小 +- ✅ 30s 监控期间无内存泄漏 + +**改回**: 测试完后改回默认 GIF(m06 作为 neutral 默认表情),方便后续 Phase 4 测试。 + +**不产出 commit**(仅验证步骤) + +--- + +### Task 3.4: 生成 GIF_REPORT.md + +**文件**: `.planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md` + +**内容**: +- 处理前后大小对比 +- 实际全帧 bbox + 裁剪窗口 +- 设备显示效果(位置、画质、流畅度) +- Phase 4 SPIFFS 总占用(背景图 + 3 GIF + 旧 m05 是否保留?) + +**commit 消息**: `docs(phase03): GIF 资源处理报告(GIF_REPORT.md)` + +## 2. 任务依赖顺序 + +``` +Task 3.1 (写脚本) → Task 3.2 (执行生成 GIF) → Task 3.3 (烧录验证) → Task 3.4 (报告) +``` + +## 3. 风险与回滚 + +| 风险 | 缓解 | +|------|------| +| PIL bbox 找不到非透明像素(背景全空) | 脚本内 `if bbox is None` 警告,fallback 用全图 | +| gifsicle 绝对路径失效(brew 更新) | 脚本顶部 `if not Path(GIFSICLE).exists()` 检查 + 降级到 PATH 查找 | +| 裁剪后人物头部被裁 | bbox 起点是非透明像素顶部,理论上头部不会被裁;如果出现,调整 crop_y | +| GIF 文件大小超 1.5MB | 加 `--lossy=30 --colors 128`,CLAUDE.md 经验显示锯齿可接受 | +| 烧录后 GIF 不显示/花屏 | 回滚 SPIFFS 镜像,检查 gifdec 解码错误 | + +**回滚**: 每个 Task 独立 commit。如果 GIF 显示问题,单独 revert Task 3.2 的 commit,spiffs_image/ 恢复到 PoC 阶段(只有 m05)。 + +## 4. Phase 3 完成验收清单 + +- [ ] Task 3.1 commit 完成(tools/prepare_hiyori_gifs.py) +- [ ] Task 3.2 commit 完成(spiffs_image/hiyori_m{03,06,07}.gif) +- [ ] Task 3.3 烧录验证 3 个 GIF 都能在设备显示 +- [ ] Task 3.4 GIF_REPORT.md commit +- [ ] 整个 Phase 3 合并为 1 个大 commit 推送 gitea + GitHub + +## 5. Phase 3 不做的事 + +- ❌ Phase 4 的情绪映射代码(22 情绪 → 3 GIF 映射表) +- ❌ Phase 5 的字幕显示恢复 +- ❌ 删除 PoC 阶段的 `spiffs_image/hiyori_m05.gif`(保留作历史参考;Phase 4 可决定是否移除) +- ❌ 修改 `bg_gif_demo.c` API(仅 Phase 3 测试时临时改路径,不动接口) +- ❌ 处理 m01/m02/m04/m05/m08 其他表情(本里程碑只用 3 个) diff --git a/main/dzbj/ai_chat_ui.c b/main/dzbj/ai_chat_ui.c index 57fb48f..0d44214 100644 --- a/main/dzbj/ai_chat_ui.c +++ b/main/dzbj/ai_chat_ui.c @@ -231,7 +231,7 @@ void ai_chat_screen_init(void) { #endif esp_err_t bgret = bg_gif_demo_start( "/spiflash/Background_360x360.jpg", - "/spiflash/hiyori_m05.gif"); + "/spiflash/hiyori_m06.gif"); // Phase 3: m06 默认表情(neutral/积极),240x320 居中 if (bgret == ESP_OK) { ESP_LOGI(TAG, "BG+GIF PoC 启动成功"); } else { diff --git a/spiffs_image/hiyori_m03.gif b/spiffs_image/hiyori_m03.gif new file mode 100644 index 0000000..7e52e90 Binary files /dev/null and b/spiffs_image/hiyori_m03.gif differ diff --git a/spiffs_image/hiyori_m05.gif b/spiffs_image/hiyori_m05.gif deleted file mode 100644 index a1aeb1c..0000000 Binary files a/spiffs_image/hiyori_m05.gif and /dev/null differ diff --git a/spiffs_image/hiyori_m06.gif b/spiffs_image/hiyori_m06.gif new file mode 100644 index 0000000..e540595 Binary files /dev/null and b/spiffs_image/hiyori_m06.gif differ diff --git a/spiffs_image/hiyori_m07.gif b/spiffs_image/hiyori_m07.gif new file mode 100644 index 0000000..163d206 Binary files /dev/null and b/spiffs_image/hiyori_m07.gif differ diff --git a/tools/prepare_hiyori_gifs.py b/tools/prepare_hiyori_gifs.py new file mode 100755 index 0000000..a49d847 --- /dev/null +++ b/tools/prepare_hiyori_gifs.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Phase 3: 准备 hiyori 表情 GIF(等比例缩小到高 360px,与 PoC 保持一致) + +输入: docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export/m{03,06,07}/hiyori_m*.gif +输出: spiffs_image/hiyori_m{03,06,07}.gif + +处理策略(与 PoC 阶段 hiyori_m05.gif 一致,复现 209×360 等比例缩放): +- 不裁剪,保持源 GIF 的人物全身完整(407×700 原始比例) +- 用 gifsicle --resize _x360 让高度 = LCD 360px,宽度按比例自动算 → 约 209px +- LVGL lv_obj_align(LV_ALIGN_CENTER) 居中显示:垂直充满 LCD,左右各 ~75px 留边 +- 不加 --lossy / --colors(保留 256 色,画质优先;CLAUDE.md 经验) +- 只用 -O3 优化文件大小 + +LCD 360×360,目标显示效果:人物充满垂直方向、横向居中、左右留边显示背景图 +""" +import subprocess +import sys +from pathlib import Path + +GIFSICLE = "/opt/homebrew/var/homebrew/tmp/.cellar/gifsicle/1.96/bin/gifsicle" +TARGET_HEIGHT = 360 # = LCD 高度,与 PoC 阶段 m05.gif 一致 + +SRC_BASE = Path("docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export") +DST_BASE = Path("spiffs_image") +TARGETS = ["m03", "m06", "m07"] + + +def check_tools(): + if not Path(GIFSICLE).exists(): + import shutil + alt = shutil.which("gifsicle") + if alt: + globals()["GIFSICLE"] = alt + print(f"使用 PATH 中的 gifsicle: {alt}") + else: + print(f"ERROR: gifsicle 不存在于 {GIFSICLE},且 PATH 中找不到", file=sys.stderr) + sys.exit(1) + + +def process_gif(src_path, dst_path): + """处理单个 GIF: gifsicle 等比例缩小到高 360px""" + src_size_kb = Path(src_path).stat().st_size / 1024 + + # 读源尺寸(仅日志用) + info = subprocess.check_output( + [GIFSICLE, "--info", str(src_path)], text=True + ) + # 解析 "logical screen WxH" + src_w, src_h, frame_count = "?", "?", "?" + for line in info.splitlines(): + if "logical screen" in line: + wh = line.split("logical screen")[1].strip().split("x") + src_w, src_h = wh[0], wh[1] + if "images" in line and line.startswith("*"): + frame_count = line.split()[-2] + + # gifsicle: 高度 = 360,宽度按比例自动算 + cmd = [ + GIFSICLE, + "--resize", f"_x{TARGET_HEIGHT}", + "-O3", + str(src_path), + "-o", str(dst_path), + ] + + print(f" 源: {src_w}x{src_h}, {frame_count} 帧, {src_size_kb:.1f} KB") + subprocess.run(cmd, check=True) + + # 验证输出尺寸 + dst_info = subprocess.check_output([GIFSICLE, "--info", str(dst_path)], text=True) + for line in dst_info.splitlines(): + if "logical screen" in line: + dst_wh = line.split("logical screen")[1].strip() + break + else: + dst_wh = "?" + + dst_size_kb = Path(dst_path).stat().st_size / 1024 + reduction = (1 - dst_size_kb / src_size_kb) * 100 + print(f" 输出: {dst_wh}, {dst_size_kb:.1f} KB (节省 {reduction:.1f}%)") + + +def main(): + check_tools() + + if not DST_BASE.exists(): + print(f"ERROR: 目标目录 {DST_BASE} 不存在", file=sys.stderr) + sys.exit(1) + + print(f"=== Phase 3: 处理 hiyori GIF (等比例缩小到高 {TARGET_HEIGHT}px) ===\n") + + total_kb = 0 + for name in TARGETS: + src = SRC_BASE / name / f"hiyori_{name}.gif" + dst = DST_BASE / f"hiyori_{name}.gif" + + if not src.exists(): + print(f"SKIP {name}: 源文件不存在 {src}") + continue + + print(f"处理 {name}...") + try: + process_gif(src, dst) + total_kb += Path(dst).stat().st_size / 1024 + except subprocess.CalledProcessError as e: + print(f" ERROR: gifsicle 失败: {e}", file=sys.stderr) + sys.exit(1) + print() + + print(f"=== 合计输出: {total_kb:.1f} KB ({total_kb / 1024:.2f} MB) ===") + + +if __name__ == "__main__": + main()