summaryrefslogtreecommitdiff
path: root/lib/lvgl/scripts/LVGLImage.py
diff options
context:
space:
mode:
authorjacqueline <me@jacqueline.id.au>2024-06-12 17:54:40 +1000
committerjacqueline <me@jacqueline.id.au>2024-06-12 17:54:40 +1000
commit64bd9053a25297f7a442ca831c7da5b44bd33f84 (patch)
treea90c6cad25a12028302ab1a5334510fba0229bae /lib/lvgl/scripts/LVGLImage.py
parent611176ed667c4ed7ee9f609e958f9404f4aee91d (diff)
downloadtangara-fw-64bd9053a25297f7a442ca831c7da5b44bd33f84.tar.gz
Update LVGL to v9.1.0
Diffstat (limited to 'lib/lvgl/scripts/LVGLImage.py')
-rwxr-xr-xlib/lvgl/scripts/LVGLImage.py1172
1 files changed, 1172 insertions, 0 deletions
diff --git a/lib/lvgl/scripts/LVGLImage.py b/lib/lvgl/scripts/LVGLImage.py
new file mode 100755
index 00000000..b2a8aa5c
--- /dev/null
+++ b/lib/lvgl/scripts/LVGLImage.py
@@ -0,0 +1,1172 @@
+#!/usr/bin/env python3
+import os
+import logging
+import argparse
+import subprocess
+from os import path
+from enum import Enum
+from typing import List
+from pathlib import Path
+
+try:
+ import png
+except ImportError:
+ raise ImportError("Need pypng package, do `pip3 install pypng`")
+
+try:
+ import lz4.block
+except ImportError:
+ raise ImportError("Need lz4 package, do `pip3 install lz4`")
+
+
+def uint8_t(val) -> bytes:
+ return val.to_bytes(1, byteorder='little')
+
+
+def uint16_t(val) -> bytes:
+ return val.to_bytes(2, byteorder='little')
+
+
+def uint24_t(val) -> bytes:
+ return val.to_bytes(3, byteorder='little')
+
+
+def uint32_t(val) -> bytes:
+ try:
+ return val.to_bytes(4, byteorder='little')
+ except OverflowError:
+ raise ParameterError(f"overflow: {hex(val)}")
+
+
+def color_pre_multiply(r, g, b, a, background):
+ bb = background & 0xff
+ bg = (background >> 8) & 0xff
+ br = (background >> 16) & 0xff
+
+ return ((r * a + (255 - a) * br) >> 8, (g * a + (255 - a) * bg) >> 8,
+ (b * a + (255 - a) * bb) >> 8, a)
+
+
+class Error(Exception):
+
+ def __str__(self):
+ return self.__class__.__name__ + ': ' + ' '.join(self.args)
+
+
+class FormatError(Error):
+ """
+ Problem with input filename format.
+ BIN filename does not conform to standard lvgl bin image format
+ """
+
+
+class ParameterError(Error):
+ """
+ Parameter for LVGL image not correct
+ """
+
+
+class PngQuant:
+ """
+ Compress PNG file to 8bit mode using `pngquant`
+ """
+
+ def __init__(self, ncolors=256, dither=True, exec_path="") -> None:
+ executable = path.join(exec_path, "pngquant")
+ self.cmd = (f"{executable} {'--nofs' if not dither else ''} "
+ f"{ncolors} --force - < ")
+
+ def convert(self, filename) -> bytes:
+ if not os.path.isfile(filename):
+ raise BaseException(f"file not found: {filename}")
+
+ try:
+ compressed = subprocess.check_output(
+ f'{self.cmd} "{str(filename)}"',
+ stderr=subprocess.STDOUT,
+ shell=True)
+ except subprocess.CalledProcessError:
+ raise BaseException(
+ "cannot find pngquant tool, install it via "
+ "`sudo apt install pngquant` for debian "
+ "or `brew install pngquant` for macintosh "
+ "For windows, you may need to download pngquant.exe from "
+ "https://pngquant.org/, and put it in your PATH.")
+
+ return compressed
+
+
+class CompressMethod(Enum):
+ NONE = 0x00
+ RLE = 0x01
+ LZ4 = 0x02
+
+
+class ColorFormat(Enum):
+ UNKNOWN = 0x00
+ L8 = 0x06
+ I1 = 0x07
+ I2 = 0x08
+ I4 = 0x09
+ I8 = 0x0A
+ A1 = 0x0B
+ A2 = 0x0C
+ A4 = 0x0D
+ A8 = 0x0E
+ ARGB8888 = 0x10
+ XRGB8888 = 0x11
+ RGB565 = 0x12
+ ARGB8565 = 0x13
+ RGB565A8 = 0x14
+ RGB888 = 0x0F
+
+ @property
+ def bpp(self) -> int:
+ """
+ Return bit per pixel for this cf
+ """
+ cf_map = {
+ ColorFormat.UNKNOWN: 0x00,
+ ColorFormat.L8: 8,
+ ColorFormat.I1: 1,
+ ColorFormat.I2: 2,
+ ColorFormat.I4: 4,
+ ColorFormat.I8: 8,
+ ColorFormat.A1: 1,
+ ColorFormat.A2: 2,
+ ColorFormat.A4: 4,
+ ColorFormat.A8: 8,
+ ColorFormat.ARGB8888: 32,
+ ColorFormat.XRGB8888: 32,
+ ColorFormat.RGB565: 16,
+ ColorFormat.RGB565A8: 16, # 16bpp + a8 map
+ ColorFormat.ARGB8565: 24,
+ ColorFormat.RGB888: 24,
+ }
+
+ return cf_map[self]
+
+ @property
+ def ncolors(self) -> int:
+ """
+ Return number of colors in palette if cf is indexed1/2/4/8.
+ Return zero if cf is not indexed format
+ """
+
+ cf_map = {
+ ColorFormat.I1: 2,
+ ColorFormat.I2: 4,
+ ColorFormat.I4: 16,
+ ColorFormat.I8: 256,
+ }
+ return cf_map.get(self, 0)
+
+ @property
+ def is_indexed(self) -> bool:
+ """
+ Return if cf is indexed color format
+ """
+ return self.ncolors != 0
+
+ @property
+ def is_alpha_only(self) -> bool:
+ return ColorFormat.A1.value <= self.value <= ColorFormat.A8.value
+
+ @property
+ def has_alpha(self) -> bool:
+ return self.is_alpha_only or self in (
+ ColorFormat.ARGB8888,
+ ColorFormat.XRGB8888, # const alpha: 0xff
+ ColorFormat.ARGB8565,
+ ColorFormat.RGB565A8)
+
+ @property
+ def is_colormap(self) -> bool:
+ return self in (ColorFormat.ARGB8888, ColorFormat.RGB888,
+ ColorFormat.XRGB8888, ColorFormat.RGB565A8,
+ ColorFormat.ARGB8565, ColorFormat.RGB565)
+
+ @property
+ def is_luma_only(self) -> bool:
+ return self in (ColorFormat.L8, )
+
+
+def bit_extend(value, bpp):
+ """
+ Extend value from bpp to 8 bit with interpolation to reduce rounding error.
+ """
+
+ if value == 0:
+ return 0
+
+ res = value
+ bpp_now = bpp
+ while bpp_now < 8:
+ res |= value << (8 - bpp_now)
+ bpp_now += bpp
+
+ return res
+
+
+def unpack_colors(data: bytes, cf: ColorFormat, w) -> List:
+ """
+ Unpack lvgl 1/2/4/8/16/32 bpp color to png color: alpha map, grey scale,
+ or R,G,B,(A) map
+ """
+ ret = []
+ bpp = cf.bpp
+ if bpp == 8:
+ ret = data
+ elif bpp == 4:
+ if cf == ColorFormat.A4:
+ values = [x * 17 for x in range(16)]
+ else:
+ values = [x for x in range(16)]
+
+ for p in data:
+ for i in range(2):
+ ret.append(values[(p >> (4 - i * 4)) & 0x0f])
+ if len(ret) % w == 0:
+ break
+
+ elif bpp == 2:
+ if cf == ColorFormat.A2:
+ values = [x * 85 for x in range(4)]
+ else: # must be ColorFormat.I2
+ values = [x for x in range(4)]
+ for p in data:
+ for i in range(4):
+ ret.append(values[(p >> (6 - i * 2)) & 0x03])
+ if len(ret) % w == 0:
+ break
+ elif bpp == 1:
+ if cf == ColorFormat.A1:
+ values = [0, 255]
+ else:
+ values = [0, 1]
+ for p in data:
+ for i in range(8):
+ ret.append(values[(p >> (7 - i)) & 0x01])
+ if len(ret) % w == 0:
+ break
+ elif bpp == 16:
+ # This is RGB565
+ pixels = [(data[2 * i + 1] << 8) | data[2 * i]
+ for i in range(len(data) // 2)]
+
+ for p in pixels:
+ ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R
+ ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G
+ ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B
+ elif bpp == 24:
+ if cf == ColorFormat.RGB888:
+ B = data[0::3]
+ G = data[1::3]
+ R = data[2::3]
+ for r, g, b in zip(R, G, B):
+ ret += [r, g, b]
+ elif cf == ColorFormat.RGB565A8:
+ alpha_size = len(data) // 3
+ pixel_alpha = data[-alpha_size:]
+ pixel_data = data[:-alpha_size]
+ pixels = [(pixel_data[2 * i + 1] << 8) | pixel_data[2 * i]
+ for i in range(len(pixel_data) // 2)]
+
+ for a, p in zip(pixel_alpha, pixels):
+ ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R
+ ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G
+ ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B
+ ret.append(a)
+ elif cf == ColorFormat.ARGB8565:
+ L = data[0::3]
+ H = data[1::3]
+ A = data[2::3]
+
+ for h, l, a in zip(H, L, A):
+ p = (h << 8) | (l)
+ ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R
+ ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G
+ ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B
+ ret.append(a) # A
+
+ elif bpp == 32:
+ B = data[0::4]
+ G = data[1::4]
+ R = data[2::4]
+ A = data[3::4]
+ for r, g, b, a in zip(R, G, B, A):
+ ret += [r, g, b, a]
+ else:
+ assert 0
+
+ return ret
+
+
+class LVGLImageHeader:
+
+ def __init__(self,
+ cf: ColorFormat = ColorFormat.UNKNOWN,
+ w: int = 0,
+ h: int = 0,
+ stride: int = 0,
+ align: int = 1,
+ flags: int = 0):
+ self.cf = cf
+ self.flags = flags
+ self.w = w & 0xffff
+ self.h = h & 0xffff
+ if w > 0xffff or h > 0xffff:
+ raise ParameterError(f"w, h overflow: {w}x{h}")
+ if align < 1:
+ # stride align in bytes must be larger than 1
+ raise ParameterError(f"Invalid stride align: {align}")
+
+ self.stride = self.stride_align(align) if stride == 0 else stride
+
+ def stride_align(self, align: int) -> int:
+ stride = self.stride_default
+ if align == 1:
+ pass
+ elif align > 1:
+ stride = (stride + align - 1) // align
+ stride *= align
+ else:
+ raise ParameterError(f"Invalid stride align: {align}")
+
+ self.stride = stride
+ return stride
+
+ @property
+ def stride_default(self) -> int:
+ return (self.w * self.cf.bpp + 7) // 8
+
+ @property
+ def binary(self) -> bytearray:
+ binary = bytearray()
+ binary += uint8_t(0x19) # magic number for lvgl version 9
+ binary += uint8_t(self.cf.value)
+ binary += uint16_t(self.flags) # 16bits flags
+
+ binary += uint16_t(self.w) # 16bits width
+ binary += uint16_t(self.h) # 16bits height
+ binary += uint16_t(self.stride) # 16bits stride
+
+ binary += uint16_t(0) # 16bits reserved
+ return binary
+
+ def from_binary(self, data: bytes):
+ if len(data) < 12:
+ raise FormatError("invalid header length")
+
+ try:
+ self.cf = ColorFormat(data[1] & 0x1f) # color format
+ except ValueError as exc:
+ raise FormatError(f"invalid color format: {hex(data[0])}") from exc
+ self.w = int.from_bytes(data[4:6], 'little')
+ self.h = int.from_bytes(data[6:8], 'little')
+ self.stride = int.from_bytes(data[8:10], 'little')
+ return self
+
+
+class LVGLCompressData:
+
+ def __init__(self,
+ cf: ColorFormat,
+ method: CompressMethod,
+ raw_data: bytes = b''):
+ self.blk_size = (cf.bpp + 7) // 8
+ self.compress = method
+ self.raw_data = raw_data
+ self.raw_data_len = len(raw_data)
+ self.compressed = self._compress(raw_data)
+
+ def _compress(self, raw_data: bytes) -> bytearray:
+ if self.compress == CompressMethod.NONE:
+ return raw_data
+
+ if self.compress == CompressMethod.RLE:
+ # RLE compression performs on pixel unit, pad data to pixel unit
+ pad = b'\x00' * (self.blk_size - self.raw_data_len % self.blk_size)
+ self.raw_data_len += len(pad)
+ compressed = RLEImage().rle_compress(raw_data + pad, self.blk_size)
+ elif self.compress == CompressMethod.LZ4:
+ compressed = lz4.block.compress(raw_data, store_size=False)
+ else:
+ raise ParameterError(f"Invalid compress method: {self.compress}")
+
+ self.compressed_len = len(compressed)
+
+ bin = bytearray()
+ bin += uint32_t(self.compress.value)
+ bin += uint32_t(self.compressed_len)
+ bin += uint32_t(self.raw_data_len)
+ bin += compressed
+ return bin
+
+
+class LVGLImage:
+
+ def __init__(self,
+ cf: ColorFormat = ColorFormat.UNKNOWN,
+ w: int = 0,
+ h: int = 0,
+ data: bytes = b'') -> None:
+ self.stride = 0 # default no valid stride value
+ self.set_data(cf, w, h, data)
+
+ def __repr__(self) -> str:
+ return (
+ f"'LVGL image {self.w}x{self.h}, {self.cf.name}, stride: {self.stride}"
+ f" (12+{self.data_len})Byte'")
+
+ def adjust_stride(self, stride: int = 0, align: int = 1):
+ """
+ Stride can be set directly, or by stride alignment in bytes
+ """
+ if self.stride == 0:
+ # stride can only be 0, when LVGLImage is created with empty data
+ logging.warning("Cannot adjust stride for empty image")
+ return
+
+ if align >= 1 and stride == 0:
+ # The header with specified stride alignment
+ header = LVGLImageHeader(self.cf, self.w, self.h, align=align)
+ stride = header.stride
+ elif stride > 0:
+ pass
+ else:
+ raise ParameterError(f"Invalid parameter, align:{align},"
+ f" stride:{stride}")
+
+ if self.stride == stride:
+ return # no stride adjustment
+
+ # if current image is empty, no need to do anything
+ if self.data_len == 0:
+ self.stride = 0
+ return
+
+ current = LVGLImageHeader(self.cf, self.w, self.h, stride=self.stride)
+
+ if stride < current.stride_default:
+ raise ParameterError(f"Stride is too small:{stride}, "
+ f"minimal:{current.stride_default}")
+
+ def change_stride(data: bytearray, h, current_stride, new_stride):
+ data_in = data
+ data_out = [] # stride adjusted new data
+ if new_stride < current_stride: # remove padding byte
+ for i in range(h):
+ start = i * current_stride
+ end = start + new_stride
+ data_out.append(data_in[start:end])
+ else: # adding more padding bytes
+ padding = b'\x00' * (new_stride - current_stride)
+ for i in range(h):
+ data_out.append(data_in[i * current_stride:(i + 1) *
+ current_stride])
+ data_out.append(padding)
+ return b''.join(data_out)
+
+ palette_size = self.cf.ncolors * 4
+ data_out = [self.data[:palette_size]]
+ data_out.append(
+ change_stride(self.data[palette_size:], self.h, current.stride,
+ stride))
+
+ # deal with alpha map for RGB565A8
+ if self.cf == ColorFormat.RGB565A8:
+ logging.warning("handle RGB565A8 alpha map")
+ a8_stride = self.stride // 2
+ a8_map = self.data[-a8_stride * self.h:]
+ data_out.append(
+ change_stride(a8_map, self.h, current.stride // 2,
+ stride // 2))
+
+ self.stride = stride
+ self.data = b''.join(data_out)
+
+ @property
+ def data_len(self) -> int:
+ """
+ Return data_len in byte of this image, excluding image header
+ """
+
+ # palette is always in ARGB format, 4Byte per color
+ p = self.cf.ncolors * 4 if self.is_indexed and self.w * self.h else 0
+ p += self.stride * self.h
+ if self.cf is ColorFormat.RGB565A8:
+ a8_stride = self.stride // 2
+ p += a8_stride * self.h
+ return p
+
+ @property
+ def header(self) -> bytearray:
+ return LVGLImageHeader(self.cf, self.w, self.h)
+
+ @property
+ def is_indexed(self):
+ return self.cf.is_indexed
+
+ def set_data(self,
+ cf: ColorFormat,
+ w: int,
+ h: int,
+ data: bytes,
+ stride: int = 0):
+ """
+ Directly set LVGL image parameters
+ """
+
+ if w > 0xffff or h > 0xffff:
+ raise ParameterError(f"w, h overflow: {w}x{h}")
+
+ self.cf = cf
+ self.w = w
+ self.h = h
+
+ # if stride is 0, then it's aligned to 1byte by default,
+ # let image header handle it
+ self.stride = LVGLImageHeader(cf, w, h, stride, align=1).stride
+
+ if self.data_len != len(data):
+ raise ParameterError(f"{self} data length error got: {len(data)}, "
+ f"expect: {self.data_len}, {self}")
+
+ self.data = data
+
+ return self
+
+ def from_data(self, data: bytes):
+ header = LVGLImageHeader().from_binary(data)
+ return self.set_data(header.cf, header.w, header.h,
+ data[len(header.binary):], header.stride)
+
+ def from_bin(self, filename: str):
+ """
+ Read from existing bin file and update image parameters
+ """
+
+ if not filename.endswith(".bin"):
+ raise FormatError("filename not ended with '.bin'")
+
+ with open(filename, "rb") as f:
+ data = f.read()
+ return self.from_data(data)
+
+ def _check_ext(self, filename: str, ext):
+ if not filename.lower().endswith(ext):
+ raise FormatError(f"filename not ended with {ext}")
+
+ def _check_dir(self, filename: str):
+ dir = path.dirname(filename)
+ if dir and not path.exists(dir):
+ logging.info(f"mkdir of {dir} for {filename}")
+ os.makedirs(dir)
+
+ def to_bin(self,
+ filename: str,
+ compress: CompressMethod = CompressMethod.NONE):
+ """
+ Write this image to file, filename should be ended with '.bin'
+ """
+ self._check_ext(filename, ".bin")
+ self._check_dir(filename)
+
+ with open(filename, "wb+") as f:
+ bin = bytearray()
+ flags = 0
+ flags |= 0x08 if compress != CompressMethod.NONE else 0
+
+ header = LVGLImageHeader(self.cf,
+ self.w,
+ self.h,
+ self.stride,
+ flags=flags)
+ bin += header.binary
+ compressed = LVGLCompressData(self.cf, compress, self.data)
+ bin += compressed.compressed
+
+ f.write(bin)
+
+ return self
+
+ def to_c_array(self,
+ filename: str,
+ compress: CompressMethod = CompressMethod.NONE):
+ self._check_ext(filename, ".c")
+ self._check_dir(filename)
+
+ varname = path.basename(filename).split('.')[0]
+ varname = varname.replace("-", "_")
+ varname = varname.replace(".", "_")
+
+ flags = "0"
+ if compress is not CompressMethod.NONE:
+ flags += " | LV_IMAGE_FLAGS_COMPRESSED"
+
+ compressed = LVGLCompressData(self.cf, compress, self.data)
+
+ header = f'''
+#if defined(LV_LVGL_H_INCLUDE_SIMPLE)
+#include "lvgl.h"
+#elif defined(LV_BUILD_TEST)
+#include "../lvgl.h"
+#else
+#include "lvgl/lvgl.h"
+#endif
+
+
+#ifndef LV_ATTRIBUTE_MEM_ALIGN
+#define LV_ATTRIBUTE_MEM_ALIGN
+#endif
+
+#ifndef LV_ATTRIBUTE_IMG_DUST
+#define LV_ATTRIBUTE_IMG_DUST
+#endif
+
+static const
+LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMG_DUST
+uint8_t {varname}_map[] = {{
+'''
+
+ ending = f'''
+}};
+
+const lv_image_dsc_t {varname} = {{
+ .header.magic = LV_IMAGE_HEADER_MAGIC,
+ .header.cf = LV_COLOR_FORMAT_{self.cf.name},
+ .header.flags = {flags},
+ .header.w = {self.w},
+ .header.h = {self.h},
+ .header.stride = {self.stride},
+ .data_size = sizeof({varname}_map),
+ .data = {varname}_map,
+}};
+
+'''
+
+ def write_binary(f, data, stride):
+ for i, v in enumerate(data):
+ if i % stride == 0:
+ f.write("\n ")
+ f.write(f"0x{v:02x},")
+ f.write("\n")
+
+ with open(filename, "w+") as f:
+ f.write(header)
+
+ if compress is not CompressMethod.NONE:
+ write_binary(f, compressed.compressed, 16)
+ else:
+ # write palette separately
+ ncolors = self.cf.ncolors
+ if ncolors:
+ write_binary(f, self.data[:ncolors * 4], 16)
+
+ write_binary(f, self.data[ncolors * 4:], self.stride)
+
+ f.write(ending)
+
+ return self
+
+ def to_png(self, filename: str):
+ self._check_ext(filename, ".png")
+ self._check_dir(filename)
+
+ old_stride = self.stride
+ self.adjust_stride(align=1)
+ if self.cf.is_indexed:
+ data = self.data
+ # Separate lvgl bin image data to palette and bitmap
+ # The palette is in format of [(RGBA), (RGBA)...].
+ # LVGL palette is in format of B,G,R,A,...
+ palette = [(data[i * 4 + 2], data[i * 4 + 1], data[i * 4 + 0],
+ data[i * 4 + 3]) for i in range(self.cf.ncolors)]
+
+ data = data[self.cf.ncolors * 4:]
+
+ encoder = png.Writer(self.w,
+ self.h,
+ palette=palette,
+ bitdepth=self.cf.bpp)
+ # separate packed data to plain data
+ data = unpack_colors(data, self.cf, self.w)
+ elif self.cf.is_alpha_only:
+ # separate packed data to plain data
+ transparency = unpack_colors(self.data, self.cf, self.w)
+ data = []
+ for a in transparency:
+ data += [0, 0, 0, a]
+ encoder = png.Writer(self.w, self.h, greyscale=False, alpha=True)
+ elif self.cf == ColorFormat.L8:
+ # to grayscale
+ encoder = png.Writer(self.w,
+ self.h,
+ bitdepth=self.cf.bpp,
+ greyscale=True,
+ alpha=False)
+ data = self.data
+ elif self.cf.is_colormap:
+ encoder = png.Writer(self.w,
+ self.h,
+ alpha=self.cf.has_alpha,
+ greyscale=False)
+ data = unpack_colors(self.data, self.cf, self.w)
+ else:
+ logging.warning(f"missing logic: {self.cf.name}")
+ return
+
+ with open(filename, "wb") as f:
+ encoder.write_array(f, data)
+
+ self.adjust_stride(stride=old_stride)
+
+ def from_png(self,
+ filename: str,
+ cf: ColorFormat = None,
+ background: int = 0x00_00_00):
+ """
+ Create lvgl image from png file.
+ If cf is none, used I1/2/4/8 based on palette size
+ """
+
+ self.background = background
+
+ if cf is None: # guess cf from filename
+ # split filename string and match with ColorFormat to check
+ # which cf to use
+ names = str(path.basename(filename)).split(".")
+ for c in names[1:-1]:
+ if c in ColorFormat.__members__:
+ cf = ColorFormat[c]
+ break
+
+ if cf is None or cf.is_indexed: # palette mode
+ self._png_to_indexed(cf, filename)
+ elif cf.is_alpha_only:
+ self._png_to_alpha_only(cf, filename)
+ elif cf.is_luma_only:
+ self._png_to_luma_only(cf, filename)
+ elif cf.is_colormap:
+ self._png_to_colormap(cf, filename)
+ else:
+ logging.warning(f"missing logic: {cf.name}")
+
+ logging.info(f"from png: {filename}, cf: {self.cf.name}")
+ return self
+
+ def _png_to_indexed(self, cf: ColorFormat, filename: str):
+ # convert to palette mode
+ auto_cf = cf is None
+ reader = png.Reader(
+ bytes=PngQuant(256 if auto_cf else cf.ncolors).convert(filename))
+ w, h, rows, _ = reader.read()
+ palette = reader.palette(alpha="force") # always return alpha
+
+ palette_len = len(palette)
+ if auto_cf:
+ if palette_len <= 2:
+ cf = ColorFormat.I1
+ elif palette_len <= 4:
+ cf = ColorFormat.I2
+ elif palette_len <= 16:
+ cf = ColorFormat.I4
+ else:
+ cf = ColorFormat.I8
+
+ if palette_len != cf.ncolors:
+ if not auto_cf:
+ logging.warning(
+ f"{path.basename(filename)} palette: {palette_len}, "
+ f"extended to: {cf.ncolors}")
+ palette += [(255, 255, 255, 0)] * (cf.ncolors - palette_len)
+
+ # Assemble lvgl image palette from PNG palette.
+ # PNG palette is a list of tuple(R,G,B,A)
+
+ rawdata = bytearray()
+ for (r, g, b, a) in palette:
+ rawdata += uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0))
+
+ # pack data if not in I8 format
+ if cf == ColorFormat.I8:
+ for e in rows:
+ rawdata += e
+ else:
+ for e in png.pack_rows(rows, cf.bpp):
+ rawdata += e
+
+ self.set_data(cf, w, h, rawdata)
+
+ def _png_to_alpha_only(self, cf: ColorFormat, filename: str):
+ reader = png.Reader(str(filename))
+ w, h, rows, info = reader.asRGBA8()
+ if not info['alpha']:
+ raise FormatError(f"{filename} has no alpha channel")
+
+ rawdata = bytearray()
+ if cf == ColorFormat.A8:
+ for row in rows:
+ A = row[3::4]
+ for e in A:
+ rawdata += uint8_t(e)
+ else:
+ shift = 8 - cf.bpp
+ mask = 2**cf.bpp - 1
+ rows = [[(a >> shift) & mask for a in row[3::4]] for row in rows]
+ for row in png.pack_rows(rows, cf.bpp):
+ rawdata += row
+
+ self.set_data(cf, w, h, rawdata)
+
+ def _png_to_luma_only(self, cf: ColorFormat, filename: str):
+ reader = png.Reader(str(filename))
+ w, h, rows, info = reader.asRGBA8()
+ rawdata = bytearray()
+ for row in rows:
+ R = row[0::4]
+ G = row[1::4]
+ B = row[2::4]
+ A = row[3::4]
+ for r, g, b, a in zip(R, G, B, A):
+ r, g, b, a = color_pre_multiply(r, g, b, a, self.background)
+ luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ rawdata += uint8_t(int(luma))
+
+ self.set_data(ColorFormat.L8, w, h, rawdata)
+
+ def _png_to_colormap(self, cf, filename: str):
+
+ if cf == ColorFormat.ARGB8888:
+
+ def pack(r, g, b, a):
+ return uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0))
+ elif cf == ColorFormat.XRGB8888:
+
+ def pack(r, g, b, a):
+ r, g, b, a = color_pre_multiply(r, g, b, a, self.background)
+ return uint32_t((0xff << 24) | (r << 16) | (g << 8) | (b << 0))
+ elif cf == ColorFormat.RGB888:
+
+ def pack(r, g, b, a):
+ r, g, b, a = color_pre_multiply(r, g, b, a, self.background)
+ return uint24_t((r << 16) | (g << 8) | (b << 0))
+ elif cf == ColorFormat.RGB565:
+
+ def pack(r, g, b, a):
+ r, g, b, a = color_pre_multiply(r, g, b, a, self.background)
+ color = (r >> 3) << 11
+ color |= (g >> 2) << 5
+ color |= (b >> 3) << 0
+ return uint16_t(color)
+ elif cf == ColorFormat.RGB565A8:
+
+ def pack(r, g, b, a):
+ color = (r >> 3) << 11
+ color |= (g >> 2) << 5
+ color |= (b >> 3) << 0
+ return uint16_t(color)
+ elif cf == ColorFormat.ARGB8565:
+
+ def pack(r, g, b, a):
+ color = (r >> 3) << 11
+ color |= (g >> 2) << 5
+ color |= (b >> 3) << 0
+ return uint24_t((a << 16) | color)
+ else:
+ raise FormatError(f"Invalid color format: {cf.name}")
+
+ reader = png.Reader(str(filename))
+ w, h, rows, _ = reader.asRGBA8()
+ rawdata = bytearray()
+ alpha = bytearray()
+ for row in rows:
+ R = row[0::4]
+ G = row[1::4]
+ B = row[2::4]
+ A = row[3::4]
+ for r, g, b, a in zip(R, G, B, A):
+ if cf == ColorFormat.RGB565A8:
+ alpha += uint8_t(a)
+ rawdata += pack(r, g, b, a)
+
+ if cf == ColorFormat.RGB565A8:
+ rawdata += alpha
+
+ self.set_data(cf, w, h, rawdata)
+
+
+class RLEHeader:
+
+ def __init__(self, blksize: int, len: int):
+ self.blksize = blksize
+ self.len = len
+
+ @property
+ def binary(self):
+ magic = 0x5aa521e0
+
+ rle_header = self.blksize
+ rle_header |= (self.len & 0xffffff) << 4
+
+ binary = bytearray()
+ binary.extend(uint32_t(magic))
+ binary.extend(uint32_t(rle_header))
+ return binary
+
+
+class RLEImage(LVGLImage):
+
+ def __init__(self,
+ cf: ColorFormat = ColorFormat.UNKNOWN,
+ w: int = 0,
+ h: int = 0,
+ data: bytes = b'') -> None:
+ super().__init__(cf, w, h, data)
+
+ def to_rle(self, filename: str):
+ """
+ Compress this image to file, filename should be ended with '.rle'
+ """
+ self._check_ext(filename, ".rle")
+ self._check_dir(filename)
+
+ # compress image data excluding lvgl image header
+ blksize = (self.cf.bpp + 7) // 8
+ compressed = self.rle_compress(self.data, blksize)
+ with open(filename, "wb+") as f:
+ header = RLEHeader(blksize, len(self.data)).binary
+ header.extend(self.header.binary)
+ f.write(header)
+ f.write(compressed)
+
+ def rle_compress(self, data: bytearray, blksize: int, threshold=16):
+ index = 0
+ data_len = len(data)
+ compressed_data = []
+ memview = memoryview(data)
+ while index < data_len:
+ repeat_cnt = self.get_repeat_count(memview[index:], blksize)
+ if repeat_cnt == 0:
+ # done
+ break
+ elif repeat_cnt < threshold:
+ nonrepeat_cnt = self.get_nonrepeat_count(
+ memview[index:], blksize, threshold)
+ ctrl_byte = uint8_t(nonrepeat_cnt | 0x80)
+ compressed_data.append(ctrl_byte)
+ compressed_data.append(memview[index:index +
+ nonrepeat_cnt * blksize])
+ index += nonrepeat_cnt * blksize
+ else:
+ ctrl_byte = uint8_t(repeat_cnt)
+ compressed_data.append(ctrl_byte)
+ compressed_data.append(memview[index:index + blksize])
+ index += repeat_cnt * blksize
+
+ return b"".join(compressed_data)
+
+ def get_repeat_count(self, data: bytearray, blksize: int):
+ if len(data) < blksize:
+ return 0
+
+ start = data[:blksize]
+ index = 0
+ repeat_cnt = 0
+ value = 0
+
+ while index < len(data):
+ value = data[index:index + blksize]
+
+ if value == start:
+ repeat_cnt += 1
+ if repeat_cnt == 127: # limit max repeat count to max value of signed char.
+ break
+ else:
+ break
+ index += blksize
+
+ return repeat_cnt
+
+ def get_nonrepeat_count(self, data: bytearray, blksize: int, threshold):
+ if len(data) < blksize:
+ return 0
+
+ pre_value = data[:blksize]
+
+ index = 0
+ nonrepeat_count = 0
+
+ repeat_cnt = 0
+ while True:
+ value = data[index:index + blksize]
+ if value == pre_value:
+ repeat_cnt += 1
+ if repeat_cnt > threshold:
+ # repeat found.
+ break
+ else:
+ pre_value = value
+ nonrepeat_count += 1 + repeat_cnt
+ repeat_cnt = 0
+ if nonrepeat_count >= 127: # limit max repeat count to max value of signed char.
+ nonrepeat_count = 127
+ break
+
+ index += blksize # move to next position
+ if index >= len(data): # data end
+ nonrepeat_count += repeat_cnt
+ break
+
+ return nonrepeat_count
+
+
+class OutputFormat(Enum):
+ C_ARRAY = "C"
+ BIN_FILE = "BIN"
+ RAW_DATA = "RAW" # option of not writing any file
+ PNG_FILE = "PNG" # convert to lvgl image and then to png
+
+
+class PNGConverter:
+
+ def __init__(self,
+ files: List,
+ cf: ColorFormat,
+ ofmt: OutputFormat,
+ odir: str,
+ background: int = 0x00,
+ align: int = 1,
+ compress: CompressMethod = CompressMethod.NONE,
+ keep_folder=True) -> None:
+ self.files = files
+ self.cf = cf
+ self.ofmt = ofmt
+ self.output = odir
+ self.pngquant = None
+ self.keep_folder = keep_folder
+ self.align = align
+ self.compress = compress
+ self.background = background
+
+ def _replace_ext(self, input, ext):
+ if self.keep_folder:
+ name, _ = path.splitext(input)
+ else:
+ name, _ = path.splitext(path.basename(input))
+ output = name + ext
+ output = path.join(self.output, output)
+ return output
+
+ def convert(self):
+ output = []
+ for f in self.files:
+ img = LVGLImage().from_png(f, self.cf, background=self.background)
+ img.adjust_stride(align=self.align)
+ output.append((f, img))
+ if self.ofmt == OutputFormat.BIN_FILE:
+ img.to_bin(self._replace_ext(f, ".bin"),
+ compress=self.compress)
+ elif self.ofmt == OutputFormat.C_ARRAY:
+ img.to_c_array(self._replace_ext(f, ".c"),
+ compress=self.compress)
+ elif self.ofmt == OutputFormat.PNG_FILE:
+ img.to_png(self._replace_ext(f, ".png"))
+
+ return output
+
+
+def main():
+ parser = argparse.ArgumentParser(description='LVGL PNG to bin image tool.')
+ parser.add_argument('--ofmt',
+ help="output filename format, C or BIN",
+ default="BIN",
+ choices=["C", "BIN", "PNG"])
+ parser.add_argument(
+ '--cf',
+ help=("bin image color format, use AUTO for automatically "
+ "choose from I1/2/4/8"),
+ default="I8",
+ choices=[
+ "L8", "I1", "I2", "I4", "I8", "A1", "A2", "A4", "A8", "ARGB8888",
+ "XRGB8888", "RGB565", "RGB565A8", "ARGB8565", "RGB888", "AUTO"
+ ])
+
+ parser.add_argument('--compress',
+ help=("Binary data compress method, default to NONE"),
+ default="NONE",
+ choices=["NONE", "RLE", "LZ4"])
+
+ parser.add_argument('--align',
+ help="stride alignment in bytes for bin image",
+ default=1,
+ type=int,
+ metavar='byte',
+ nargs='?')
+ parser.add_argument('--background',
+ help="Background color for formats without alpha",
+ default=0x00_00_00,
+ type=lambda x: int(x, 0),
+ metavar='color',
+ nargs='?')
+ parser.add_argument('-o',
+ '--output',
+ default="./output",
+ help="Select the output folder, default to ./output")
+ parser.add_argument('-v', '--verbose', action='store_true')
+ parser.add_argument(
+ 'input', help="the filename or folder to be recursively converted")
+
+ args = parser.parse_args()
+
+ if path.isfile(args.input):
+ files = [args.input]
+ elif path.isdir(args.input):
+ files = list(Path(args.input).rglob("*.[pP][nN][gG]"))
+ else:
+ raise BaseException(f"invalid input: {args.input}")
+
+ if args.verbose:
+ logging.basicConfig(level=logging.INFO)
+
+ logging.info(f"options: {args.__dict__}, files:{[str(f) for f in files]}")
+
+ if args.cf == "AUTO":
+ cf = None
+ else:
+ cf = ColorFormat[args.cf]
+
+ ofmt = OutputFormat(args.ofmt)
+ compress = CompressMethod[args.compress]
+
+ converter = PNGConverter(files,
+ cf,
+ ofmt,
+ args.output,
+ background=args.background,
+ align=args.align,
+ compress=compress,
+ keep_folder=False)
+ output = converter.convert()
+ for f, img in output:
+ logging.info(f"len: {img.data_len} for {path.basename(f)} ")
+
+ print(f"done {len(files)} files")
+
+
+def test():
+ logging.basicConfig(level=logging.INFO)
+ f = "pngs/cogwheel.RGB565A8.png"
+ img = LVGLImage().from_png(f,
+ cf=ColorFormat.ARGB8565,
+ background=0xFF_FF_00)
+ img.adjust_stride(align=16)
+ img.to_bin("output/cogwheel.ARGB8565.bin")
+ img.to_c_array("output/cogwheel-abc.c") # file name is used as c var name
+ img.to_png("output/cogwheel.ARGB8565.png.png") # convert back to png
+
+
+if __name__ == "__main__":
+ # test()
+ main()