190 lines
6.8 KiB
Python
190 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
i1_to_png.py — 将 LVGL 模拟器 `--screenshot` 产出的 I1(1bpp) 原始位图转换为 PNG。
|
||
|
||
输入格式(模拟器 main.c 的 --screenshot 模式输出):
|
||
- 1 位/像素,每字节 MSB 在左(bit7=该行最左像素),bit=1 为前景、bit=0 为背景
|
||
- 每行 stride = 宽 / 8 字节,行内无填充
|
||
- 模拟器当前已剥离 8 字节调色板,文件即纯像素数据(宽/8 × 高 字节)
|
||
- 若遇到仍含调色板的旧文件(像素数据 + 8 字节),--auto-palette 会自动识别并跳过
|
||
|
||
用法示例:
|
||
python i1_to_png.py shot.bin # -> shot.png(默认 160x160)
|
||
python i1_to_png.py shot.bin -o out.png # 指定输出
|
||
python i1_to_png.py shot.bin --scale 6 # 放大 6 倍便于查看(最近邻,保持像素感)
|
||
python i1_to_png.py shot.bin -W 320 -H 240 # 非默认分辨率
|
||
python i1_to_png.py shot.bin --fg 0,0,0 --bg 255,255,255 # 自定义前/背景色(RGB 模式)
|
||
python i1_to_png.py shot.bin --invert # 前景/背景互换
|
||
|
||
依赖:Pillow(pip install pillow)。本仓库环境已验证 PIL 12.2.0。
|
||
退出码:0 成功;1 参数/数据错误。
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
|
||
# DTU 屏幕默认分辨率(160x160,1bpp)
|
||
DEFAULT_W = 160
|
||
DEFAULT_H = 160
|
||
# I1 调色板大小(2 色 × 4 字节)。模拟器新版已剥离,旧文件可能残留。
|
||
I1_PALETTE_BYTES = 2 * 4
|
||
|
||
|
||
def _decode_i1(data, width, height):
|
||
"""把原始 I1 数据解成 (height, width) 的 0/1 矩阵。MSB 在左。"""
|
||
stride = width // 8
|
||
if width % 8 != 0:
|
||
raise ValueError("宽度必须是 8 的整数倍(I1 按字节打包),当前 %d" % width)
|
||
need = stride * height
|
||
if len(data) < need:
|
||
raise ValueError("数据不足:%d 字节 < 需要 %d 字节(%dx%d I1)"
|
||
% (len(data), need, width, height))
|
||
px = []
|
||
for y in range(height):
|
||
base = y * stride
|
||
row = [0] * width
|
||
for x in range(width):
|
||
byte_i = x >> 3
|
||
bit_i = 7 - (x & 7)
|
||
row[x] = (data[base + byte_i] >> bit_i) & 1
|
||
px.append(row)
|
||
return px
|
||
|
||
|
||
def _pick_palette_offset(data, width, height, auto):
|
||
"""返回像素数据在文件中的起始偏移(跳过调色板)。"""
|
||
stride = width // 8
|
||
need = stride * height
|
||
sizes = [len(data)]
|
||
if auto:
|
||
# 像素数据前可能带 I1 调色板(8 字节)
|
||
if len(data) == need + I1_PALETTE_BYTES:
|
||
return I1_PALETTE_BYTES
|
||
if len(data) < need:
|
||
raise ValueError("数据不足:%d 字节 < 像素所需 %d 字节" % (len(data), need))
|
||
return 0
|
||
|
||
|
||
def i1_to_png(input_path, output_path=None, width=DEFAULT_W, height=DEFAULT_H,
|
||
scale=1, invert=False, auto_palette=True,
|
||
fg=None, bg=None):
|
||
"""核心转换函数,可被其他脚本直接 import 调用。
|
||
|
||
fg / bg 形如 (r,g,b);为 None 时用黑白(L 模式)。
|
||
返回输出文件路径。
|
||
"""
|
||
from PIL import Image
|
||
|
||
if not os.path.isfile(input_path):
|
||
raise FileNotFoundError(input_path)
|
||
|
||
with open(input_path, "rb") as f:
|
||
data = f.read()
|
||
|
||
off = _pick_palette_offset(data, width, height, auto_palette)
|
||
px = _decode_i1(data[off:], width, height)
|
||
|
||
nw, nh = width, height
|
||
|
||
if fg is None and bg is None:
|
||
# 灰度:bit=1 -> 白(255),bit=0 -> 黑(0)
|
||
img = Image.new("L", (nw, nh))
|
||
ipx = img.load()
|
||
for y in range(nh):
|
||
for x in range(nw):
|
||
bit = px[y][x]
|
||
if invert:
|
||
bit ^= 1
|
||
ipx[x, y] = 255 if bit else 0
|
||
else:
|
||
fgc = fg if fg is not None else (255, 255, 255)
|
||
bgc = bg if bg is not None else (0, 0, 0)
|
||
if invert:
|
||
fgc, bgc = bgc, fgc
|
||
img = Image.new("RGB", (nw, nh), bgc)
|
||
ipx = img.load()
|
||
for y in range(nh):
|
||
for x in range(nw):
|
||
bit = px[y][x]
|
||
ipx[x, y] = fgc if bit else bgc
|
||
|
||
# 放大(最近邻,保持 1bpp 像素感)
|
||
if scale and scale > 1:
|
||
img = img.resize((nw * scale, nh * scale), Image.NEAREST)
|
||
|
||
if output_path is None:
|
||
output_path = os.path.splitext(input_path)[0] + ".png"
|
||
img.save(output_path, "PNG")
|
||
return output_path
|
||
|
||
|
||
def _parse_rgb(s):
|
||
if s is None:
|
||
return None
|
||
parts = s.split(",")
|
||
if len(parts) != 3:
|
||
raise argparse.ArgumentTypeError("颜色格式应为 R,G,B,如 255,0,0")
|
||
try:
|
||
r, g, b = (int(p) for p in parts)
|
||
except ValueError:
|
||
raise argparse.ArgumentTypeError("颜色分量必须是整数")
|
||
for v in (r, g, b):
|
||
if not (0 <= v <= 255):
|
||
raise argparse.ArgumentTypeError("颜色分量需在 0~255")
|
||
return (r, g, b)
|
||
|
||
|
||
def _ensure_utf8_console():
|
||
"""Windows 控制台默认 GBK,中文会乱码;统一切到 UTF-8(不支持则静默跳过)。"""
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8")
|
||
except (AttributeError, ValueError):
|
||
pass
|
||
|
||
|
||
def main(argv=None):
|
||
_ensure_utf8_console()
|
||
ap = argparse.ArgumentParser(
|
||
description="将模拟器 I1(1bpp) 原始截图转换为 PNG",
|
||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||
)
|
||
ap.add_argument("input", help="I1 原始位图文件(.bin)")
|
||
ap.add_argument("-o", "--output", default=None,
|
||
help="输出 PNG 路径(默认与输入同名 .png)")
|
||
ap.add_argument("-W", "--width", type=int, default=DEFAULT_W, help="图像宽度")
|
||
ap.add_argument("-H", "--height", type=int, default=DEFAULT_H, help="图像高度")
|
||
ap.add_argument("--scale", type=int, default=1,
|
||
help="放大倍数(最近邻,便于查看小图)")
|
||
ap.add_argument("--invert", action="store_true", help="前景/背景互换")
|
||
ap.add_argument("--no-auto-palette", action="store_true",
|
||
help="不做调色板自动识别(按纯像素数据解析)")
|
||
ap.add_argument("--fg", type=_parse_rgb, default=None, help="前景色 R,G,B(bit=1)")
|
||
ap.add_argument("--bg", type=_parse_rgb, default=None, help="背景色 R,G,B(bit=0)")
|
||
args = ap.parse_args(argv)
|
||
|
||
try:
|
||
out = i1_to_png(
|
||
args.input,
|
||
output_path=args.output,
|
||
width=args.width,
|
||
height=args.height,
|
||
scale=args.scale,
|
||
invert=args.invert,
|
||
auto_palette=not args.no_auto_palette,
|
||
fg=args.fg,
|
||
bg=args.bg,
|
||
)
|
||
except (ValueError, FileNotFoundError) as e:
|
||
print("错误:%s" % e, file=sys.stderr)
|
||
return 1
|
||
|
||
print("已生成:%s" % out)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|