1#!/usr/bin/env python3 2import os 3import logging 4import argparse 5import subprocess 6from os import path 7from enum import Enum 8from typing import List 9from pathlib import Path 10 11try: 12 import png 13except ImportError: 14 raise ImportError("Need pypng package, do `pip3 install pypng`") 15 16try: 17 import lz4.block 18except ImportError: 19 raise ImportError("Need lz4 package, do `pip3 install lz4`") 20 21 22def uint8_t(val) -> bytes: 23 return val.to_bytes(1, byteorder='little') 24 25 26def uint16_t(val) -> bytes: 27 return val.to_bytes(2, byteorder='little') 28 29 30def uint24_t(val) -> bytes: 31 return val.to_bytes(3, byteorder='little') 32 33 34def uint32_t(val) -> bytes: 35 try: 36 return val.to_bytes(4, byteorder='little') 37 except OverflowError: 38 raise ParameterError(f"overflow: {hex(val)}") 39 40 41def color_pre_multiply(r, g, b, a, background): 42 bb = background & 0xff 43 bg = (background >> 8) & 0xff 44 br = (background >> 16) & 0xff 45 46 return ((r * a + (255 - a) * br) >> 8, (g * a + (255 - a) * bg) >> 8, 47 (b * a + (255 - a) * bb) >> 8, a) 48 49 50class Error(Exception): 51 52 def __str__(self): 53 return self.__class__.__name__ + ': ' + ' '.join(self.args) 54 55 56class FormatError(Error): 57 """ 58 Problem with input filename format. 59 BIN filename does not conform to standard lvgl bin image format 60 """ 61 62 63class ParameterError(Error): 64 """ 65 Parameter for LVGL image not correct 66 """ 67 68 69class PngQuant: 70 """ 71 Compress PNG file to 8bit mode using `pngquant` 72 """ 73 74 def __init__(self, ncolors=256, dither=True, exec_path="") -> None: 75 executable = path.join(exec_path, "pngquant") 76 self.cmd = (f"{executable} {'--nofs' if not dither else ''} " 77 f"{ncolors} --force - < ") 78 79 def convert(self, filename) -> bytes: 80 if not os.path.isfile(filename): 81 raise BaseException(f"file not found: {filename}") 82 83 try: 84 compressed = subprocess.check_output( 85 f'{self.cmd} "{str(filename)}"', 86 stderr=subprocess.STDOUT, 87 shell=True) 88 except subprocess.CalledProcessError: 89 raise BaseException( 90 "cannot find pngquant tool, install it via " 91 "`sudo apt install pngquant` for debian " 92 "or `brew install pngquant` for macintosh " 93 "For windows, you may need to download pngquant.exe from " 94 "https://pngquant.org/, and put it in your PATH.") 95 96 return compressed 97 98 99class CompressMethod(Enum): 100 NONE = 0x00 101 RLE = 0x01 102 LZ4 = 0x02 103 104 105class ColorFormat(Enum): 106 UNKNOWN = 0x00 107 RAW = 0x01, 108 RAW_ALPHA = 0x02, 109 L8 = 0x06 110 I1 = 0x07 111 I2 = 0x08 112 I4 = 0x09 113 I8 = 0x0A 114 A1 = 0x0B 115 A2 = 0x0C 116 A4 = 0x0D 117 A8 = 0x0E 118 ARGB8888 = 0x10 119 XRGB8888 = 0x11 120 RGB565 = 0x12 121 ARGB8565 = 0x13 122 RGB565A8 = 0x14 123 RGB888 = 0x0F 124 125 @property 126 def bpp(self) -> int: 127 """ 128 Return bit per pixel for this cf 129 """ 130 cf_map = { 131 ColorFormat.L8: 8, 132 ColorFormat.I1: 1, 133 ColorFormat.I2: 2, 134 ColorFormat.I4: 4, 135 ColorFormat.I8: 8, 136 ColorFormat.A1: 1, 137 ColorFormat.A2: 2, 138 ColorFormat.A4: 4, 139 ColorFormat.A8: 8, 140 ColorFormat.ARGB8888: 32, 141 ColorFormat.XRGB8888: 32, 142 ColorFormat.RGB565: 16, 143 ColorFormat.RGB565A8: 16, # 16bpp + a8 map 144 ColorFormat.ARGB8565: 24, 145 ColorFormat.RGB888: 24, 146 } 147 148 return cf_map[self] if self in cf_map else 0 149 150 @property 151 def ncolors(self) -> int: 152 """ 153 Return number of colors in palette if cf is indexed1/2/4/8. 154 Return zero if cf is not indexed format 155 """ 156 157 cf_map = { 158 ColorFormat.I1: 2, 159 ColorFormat.I2: 4, 160 ColorFormat.I4: 16, 161 ColorFormat.I8: 256, 162 } 163 return cf_map.get(self, 0) 164 165 @property 166 def is_indexed(self) -> bool: 167 """ 168 Return if cf is indexed color format 169 """ 170 return self.ncolors != 0 171 172 @property 173 def is_alpha_only(self) -> bool: 174 return ColorFormat.A1.value <= self.value <= ColorFormat.A8.value 175 176 @property 177 def has_alpha(self) -> bool: 178 return self.is_alpha_only or self.is_indexed or self in ( 179 ColorFormat.ARGB8888, 180 ColorFormat.XRGB8888, # const alpha: 0xff 181 ColorFormat.ARGB8565, 182 ColorFormat.RGB565A8) 183 184 @property 185 def is_colormap(self) -> bool: 186 return self in (ColorFormat.ARGB8888, ColorFormat.RGB888, 187 ColorFormat.XRGB8888, ColorFormat.RGB565A8, 188 ColorFormat.ARGB8565, ColorFormat.RGB565) 189 190 @property 191 def is_luma_only(self) -> bool: 192 return self in (ColorFormat.L8, ) 193 194 195def bit_extend(value, bpp): 196 """ 197 Extend value from bpp to 8 bit with interpolation to reduce rounding error. 198 """ 199 200 if value == 0: 201 return 0 202 203 res = value 204 bpp_now = bpp 205 while bpp_now < 8: 206 res |= value << (8 - bpp_now) 207 bpp_now += bpp 208 209 return res 210 211 212def unpack_colors(data: bytes, cf: ColorFormat, w) -> List: 213 """ 214 Unpack lvgl 1/2/4/8/16/32 bpp color to png color: alpha map, grey scale, 215 or R,G,B,(A) map 216 """ 217 ret = [] 218 bpp = cf.bpp 219 if bpp == 8: 220 ret = data 221 elif bpp == 4: 222 if cf == ColorFormat.A4: 223 values = [x * 17 for x in range(16)] 224 else: 225 values = [x for x in range(16)] 226 227 for p in data: 228 for i in range(2): 229 ret.append(values[(p >> (4 - i * 4)) & 0x0f]) 230 if len(ret) % w == 0: 231 break 232 233 elif bpp == 2: 234 if cf == ColorFormat.A2: 235 values = [x * 85 for x in range(4)] 236 else: # must be ColorFormat.I2 237 values = [x for x in range(4)] 238 for p in data: 239 for i in range(4): 240 ret.append(values[(p >> (6 - i * 2)) & 0x03]) 241 if len(ret) % w == 0: 242 break 243 elif bpp == 1: 244 if cf == ColorFormat.A1: 245 values = [0, 255] 246 else: 247 values = [0, 1] 248 for p in data: 249 for i in range(8): 250 ret.append(values[(p >> (7 - i)) & 0x01]) 251 if len(ret) % w == 0: 252 break 253 elif bpp == 16: 254 # This is RGB565 255 pixels = [(data[2 * i + 1] << 8) | data[2 * i] 256 for i in range(len(data) // 2)] 257 258 for p in pixels: 259 ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R 260 ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G 261 ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B 262 elif bpp == 24: 263 if cf == ColorFormat.RGB888: 264 B = data[0::3] 265 G = data[1::3] 266 R = data[2::3] 267 for r, g, b in zip(R, G, B): 268 ret += [r, g, b] 269 elif cf == ColorFormat.RGB565A8: 270 alpha_size = len(data) // 3 271 pixel_alpha = data[-alpha_size:] 272 pixel_data = data[:-alpha_size] 273 pixels = [(pixel_data[2 * i + 1] << 8) | pixel_data[2 * i] 274 for i in range(len(pixel_data) // 2)] 275 276 for a, p in zip(pixel_alpha, pixels): 277 ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R 278 ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G 279 ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B 280 ret.append(a) 281 elif cf == ColorFormat.ARGB8565: 282 L = data[0::3] 283 H = data[1::3] 284 A = data[2::3] 285 286 for h, l, a in zip(H, L, A): 287 p = (h << 8) | (l) 288 ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R 289 ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G 290 ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B 291 ret.append(a) # A 292 293 elif bpp == 32: 294 B = data[0::4] 295 G = data[1::4] 296 R = data[2::4] 297 A = data[3::4] 298 for r, g, b, a in zip(R, G, B, A): 299 ret += [r, g, b, a] 300 else: 301 assert 0 302 303 return ret 304 305 306def write_c_array_file( 307 w: int, h: int, 308 stride: int, 309 cf: ColorFormat, 310 filename: str, 311 premultiplied: bool, 312 compress: CompressMethod, 313 data: bytes): 314 varname = path.basename(filename).split('.')[0] 315 varname = varname.replace("-", "_") 316 varname = varname.replace(".", "_") 317 318 flags = "0" 319 if compress is not CompressMethod.NONE: 320 flags += " | LV_IMAGE_FLAGS_COMPRESSED" 321 if premultiplied: 322 flags += " | LV_IMAGE_FLAGS_PREMULTIPLIED" 323 324 macro = "LV_ATTRIBUTE_" + varname.upper() 325 header = f''' 326#if defined(LV_LVGL_H_INCLUDE_SIMPLE) 327#include "lvgl.h" 328#elif defined(LV_BUILD_TEST) 329#include "../lvgl.h" 330#else 331#include "lvgl/lvgl.h" 332#endif 333 334 335#ifndef LV_ATTRIBUTE_MEM_ALIGN 336#define LV_ATTRIBUTE_MEM_ALIGN 337#endif 338 339#ifndef {macro} 340#define {macro} 341#endif 342 343static const 344LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST {macro} 345uint8_t {varname}_map[] = {{ 346''' 347 348 ending = f''' 349}}; 350 351const lv_image_dsc_t {varname} = {{ 352 .header.magic = LV_IMAGE_HEADER_MAGIC, 353 .header.cf = LV_COLOR_FORMAT_{cf.name}, 354 .header.flags = {flags}, 355 .header.w = {w}, 356 .header.h = {h}, 357 .header.stride = {stride}, 358 .data_size = sizeof({varname}_map), 359 .data = {varname}_map, 360}}; 361 362''' 363 364 def write_binary(f, data, stride): 365 stride = 16 if stride == 0 else stride 366 for i, v in enumerate(data): 367 if i % stride == 0: 368 f.write("\n ") 369 f.write(f"0x{v:02x},") 370 f.write("\n") 371 372 with open(filename, "w+") as f: 373 f.write(header) 374 375 if compress != CompressMethod.NONE: 376 write_binary(f, data, 16) 377 else: 378 # write palette separately 379 ncolors = cf.ncolors 380 if ncolors: 381 write_binary(f, data[:ncolors * 4], 16) 382 383 write_binary(f, data[ncolors * 4:], stride) 384 385 f.write(ending) 386 387 388class LVGLImageHeader: 389 390 def __init__(self, 391 cf: ColorFormat = ColorFormat.UNKNOWN, 392 w: int = 0, 393 h: int = 0, 394 stride: int = 0, 395 align: int = 1, 396 flags: int = 0): 397 self.cf = cf 398 self.flags = flags 399 self.w = w & 0xffff 400 self.h = h & 0xffff 401 if w > 0xffff or h > 0xffff: 402 raise ParameterError(f"w, h overflow: {w}x{h}") 403 if align < 1: 404 # stride align in bytes must be larger than 1 405 raise ParameterError(f"Invalid stride align: {align}") 406 407 self.stride = self.stride_align(align) if stride == 0 else stride 408 409 def stride_align(self, align: int) -> int: 410 stride = self.stride_default 411 if align == 1: 412 pass 413 elif align > 1: 414 stride = (stride + align - 1) // align 415 stride *= align 416 else: 417 raise ParameterError(f"Invalid stride align: {align}") 418 419 self.stride = stride 420 return stride 421 422 @property 423 def stride_default(self) -> int: 424 return (self.w * self.cf.bpp + 7) // 8 425 426 @property 427 def binary(self) -> bytearray: 428 binary = bytearray() 429 binary += uint8_t(0x19) # magic number for lvgl version 9 430 binary += uint8_t(self.cf.value) 431 binary += uint16_t(self.flags) # 16bits flags 432 433 binary += uint16_t(self.w) # 16bits width 434 binary += uint16_t(self.h) # 16bits height 435 binary += uint16_t(self.stride) # 16bits stride 436 437 binary += uint16_t(0) # 16bits reserved 438 return binary 439 440 def from_binary(self, data: bytes): 441 if len(data) < 12: 442 raise FormatError("invalid header length") 443 444 try: 445 self.cf = ColorFormat(data[1] & 0x1f) # color format 446 except ValueError as exc: 447 raise FormatError(f"invalid color format: {hex(data[0])}") from exc 448 self.w = int.from_bytes(data[4:6], 'little') 449 self.h = int.from_bytes(data[6:8], 'little') 450 self.stride = int.from_bytes(data[8:10], 'little') 451 return self 452 453 454class LVGLCompressData: 455 456 def __init__(self, 457 cf: ColorFormat, 458 method: CompressMethod, 459 raw_data: bytes = b''): 460 self.blk_size = (cf.bpp + 7) // 8 461 self.compress = method 462 self.raw_data = raw_data 463 self.raw_data_len = len(raw_data) 464 self.compressed = self._compress(raw_data) 465 466 def _compress(self, raw_data: bytes) -> bytearray: 467 if self.compress == CompressMethod.NONE: 468 return raw_data 469 470 if self.compress == CompressMethod.RLE: 471 # RLE compression performs on pixel unit, pad data to pixel unit 472 pad = b'\x00' * 0 473 if self.raw_data_len % self.blk_size: 474 pad = b'\x00' * (self.blk_size - self.raw_data_len % self.blk_size) 475 compressed = RLEImage().rle_compress(raw_data + pad, self.blk_size) 476 elif self.compress == CompressMethod.LZ4: 477 compressed = lz4.block.compress(raw_data, store_size=False) 478 else: 479 raise ParameterError(f"Invalid compress method: {self.compress}") 480 481 self.compressed_len = len(compressed) 482 483 bin = bytearray() 484 bin += uint32_t(self.compress.value) 485 bin += uint32_t(self.compressed_len) 486 bin += uint32_t(self.raw_data_len) 487 bin += compressed 488 return bin 489 490 491class LVGLImage: 492 493 def __init__(self, 494 cf: ColorFormat = ColorFormat.UNKNOWN, 495 w: int = 0, 496 h: int = 0, 497 data: bytes = b'') -> None: 498 self.stride = 0 # default no valid stride value 499 self.premultiplied = False 500 self.rgb565_dither = False 501 self.set_data(cf, w, h, data) 502 503 def __repr__(self) -> str: 504 return (f"'LVGL image {self.w}x{self.h}, {self.cf.name}, " 505 f"{'Pre-multiplied, ' if self.premultiplied else ''}" 506 f"stride: {self.stride} " 507 f"(12+{self.data_len})Byte'") 508 509 def adjust_stride(self, stride: int = 0, align: int = 1): 510 """ 511 Stride can be set directly, or by stride alignment in bytes 512 """ 513 if self.stride == 0: 514 # stride can only be 0, when LVGLImage is created with empty data 515 logging.warning("Cannot adjust stride for empty image") 516 return 517 518 if align >= 1 and stride == 0: 519 # The header with specified stride alignment 520 header = LVGLImageHeader(self.cf, self.w, self.h, align=align) 521 stride = header.stride 522 elif stride > 0: 523 pass 524 else: 525 raise ParameterError(f"Invalid parameter, align:{align}," 526 f" stride:{stride}") 527 528 if self.stride == stride: 529 return # no stride adjustment 530 531 # if current image is empty, no need to do anything 532 if self.data_len == 0: 533 self.stride = 0 534 return 535 536 current = LVGLImageHeader(self.cf, self.w, self.h, stride=self.stride) 537 538 if stride < current.stride_default: 539 raise ParameterError(f"Stride is too small:{stride}, " 540 f"minimal:{current.stride_default}") 541 542 def change_stride(data: bytearray, h, current_stride, new_stride): 543 data_in = data 544 data_out = [] # stride adjusted new data 545 if new_stride < current_stride: # remove padding byte 546 for i in range(h): 547 start = i * current_stride 548 end = start + new_stride 549 data_out.append(data_in[start:end]) 550 else: # adding more padding bytes 551 padding = b'\x00' * (new_stride - current_stride) 552 for i in range(h): 553 data_out.append(data_in[i * current_stride:(i + 1) * 554 current_stride]) 555 data_out.append(padding) 556 return b''.join(data_out) 557 558 palette_size = self.cf.ncolors * 4 559 data_out = [self.data[:palette_size]] 560 data_out.append( 561 change_stride(self.data[palette_size:], self.h, current.stride, 562 stride)) 563 564 # deal with alpha map for RGB565A8 565 if self.cf == ColorFormat.RGB565A8: 566 logging.warning("handle RGB565A8 alpha map") 567 a8_stride = self.stride // 2 568 a8_map = self.data[-a8_stride * self.h:] 569 data_out.append( 570 change_stride(a8_map, self.h, current.stride // 2, 571 stride // 2)) 572 573 self.stride = stride 574 self.data = bytearray(b''.join(data_out)) 575 576 def premultiply(self): 577 """ 578 Pre-multiply image RGB data with alpha, set corresponding image header flags 579 """ 580 if self.premultiplied: 581 raise ParameterError("Image already pre-multiplied") 582 583 if not self.cf.has_alpha: 584 raise ParameterError(f"Image has no alpha channel: {self.cf.name}") 585 586 if self.cf.is_indexed: 587 588 def multiply(r, g, b, a): 589 r, g, b = (r * a) >> 8, (g * a) >> 8, (b * a) >> 8 590 return uint8_t(b) + uint8_t(g) + uint8_t(r) + uint8_t(a) 591 592 # process the palette only. 593 palette_size = self.cf.ncolors * 4 594 palette = self.data[:palette_size] 595 palette = [ 596 multiply(palette[i], palette[i + 1], palette[i + 2], 597 palette[i + 3]) for i in range(0, len(palette), 4) 598 ] 599 palette = b''.join(palette) 600 self.data = palette + self.data[palette_size:] 601 elif self.cf is ColorFormat.ARGB8888: 602 603 def multiply(b, g, r, a): 604 r, g, b = (r * a) >> 8, (g * a) >> 8, (b * a) >> 8 605 return uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) 606 607 line_width = self.w * 4 608 for h in range(self.h): 609 offset = h * self.stride 610 map = self.data[offset:offset + self.stride] 611 612 processed = b''.join([ 613 multiply(map[i], map[i + 1], map[i + 2], map[i + 3]) 614 for i in range(0, line_width, 4) 615 ]) 616 self.data[offset:offset + line_width] = processed 617 elif self.cf is ColorFormat.RGB565A8: 618 619 def multiply(data, a): 620 r = (data >> 11) & 0x1f 621 g = (data >> 5) & 0x3f 622 b = (data >> 0) & 0x1f 623 624 r, g, b = (r * a) // 255, (g * a) // 255, (b * a) // 255 625 return uint16_t((r << 11) | (g << 5) | (b << 0)) 626 627 line_width = self.w * 2 628 for h in range(self.h): 629 # alpha map offset for this line 630 offset = self.h * self.stride + h * (self.stride // 2) 631 a = self.data[offset:offset + self.stride // 2] 632 633 # RGB map offset 634 offset = h * self.stride 635 rgb = self.data[offset:offset + self.stride] 636 637 processed = b''.join([ 638 multiply((rgb[i + 1] << 8) | rgb[i], a[i // 2]) 639 for i in range(0, line_width, 2) 640 ]) 641 self.data[offset:offset + line_width] = processed 642 elif self.cf is ColorFormat.ARGB8565: 643 644 def multiply(data, a): 645 r = (data >> 11) & 0x1f 646 g = (data >> 5) & 0x3f 647 b = (data >> 0) & 0x1f 648 649 r, g, b = (r * a) // 255, (g * a) // 255, (b * a) // 255 650 return uint24_t((a << 16) | (r << 11) | (g << 5) | (b << 0)) 651 652 line_width = self.w * 3 653 for h in range(self.h): 654 offset = h * self.stride 655 map = self.data[offset:offset + self.stride] 656 657 processed = b''.join([ 658 multiply((map[i + 1] << 8) | map[i], map[i + 2]) 659 for i in range(0, line_width, 3) 660 ]) 661 self.data[offset:offset + line_width] = processed 662 else: 663 raise ParameterError(f"Not supported yet: {self.cf.name}") 664 665 self.premultiplied = True 666 667 @property 668 def data_len(self) -> int: 669 """ 670 Return data_len in byte of this image, excluding image header 671 """ 672 673 # palette is always in ARGB format, 4Byte per color 674 p = self.cf.ncolors * 4 if self.is_indexed and self.w * self.h else 0 675 p += self.stride * self.h 676 if self.cf is ColorFormat.RGB565A8: 677 a8_stride = self.stride // 2 678 p += a8_stride * self.h 679 return p 680 681 @property 682 def header(self) -> bytearray: 683 return LVGLImageHeader(self.cf, self.w, self.h) 684 685 @property 686 def is_indexed(self): 687 return self.cf.is_indexed 688 689 def set_data(self, 690 cf: ColorFormat, 691 w: int, 692 h: int, 693 data: bytes, 694 stride: int = 0): 695 """ 696 Directly set LVGL image parameters 697 """ 698 699 if w > 0xffff or h > 0xffff: 700 raise ParameterError(f"w, h overflow: {w}x{h}") 701 702 self.cf = cf 703 self.w = w 704 self.h = h 705 706 # if stride is 0, then it's aligned to 1byte by default, 707 # let image header handle it 708 self.stride = LVGLImageHeader(cf, w, h, stride, align=1).stride 709 710 if self.data_len != len(data): 711 raise ParameterError(f"{self} data length error got: {len(data)}, " 712 f"expect: {self.data_len}, {self}") 713 714 self.data = data 715 716 return self 717 718 def from_data(self, data: bytes): 719 header = LVGLImageHeader().from_binary(data) 720 return self.set_data(header.cf, header.w, header.h, 721 data[len(header.binary):], header.stride) 722 723 def from_bin(self, filename: str): 724 """ 725 Read from existing bin file and update image parameters 726 """ 727 728 if not filename.endswith(".bin"): 729 raise FormatError("filename not ended with '.bin'") 730 731 with open(filename, "rb") as f: 732 data = f.read() 733 return self.from_data(data) 734 735 def _check_ext(self, filename: str, ext): 736 if not filename.lower().endswith(ext): 737 raise FormatError(f"filename not ended with {ext}") 738 739 def _check_dir(self, filename: str): 740 dir = path.dirname(filename) 741 if dir and not path.exists(dir): 742 logging.info(f"mkdir of {dir} for {filename}") 743 os.makedirs(dir) 744 745 def to_bin(self, 746 filename: str, 747 compress: CompressMethod = CompressMethod.NONE): 748 """ 749 Write this image to file, filename should be ended with '.bin' 750 """ 751 self._check_ext(filename, ".bin") 752 self._check_dir(filename) 753 754 with open(filename, "wb+") as f: 755 bin = bytearray() 756 flags = 0 757 flags |= 0x08 if compress != CompressMethod.NONE else 0 758 flags |= 0x01 if self.premultiplied else 0 759 760 header = LVGLImageHeader(self.cf, 761 self.w, 762 self.h, 763 self.stride, 764 flags=flags) 765 bin += header.binary 766 compressed = LVGLCompressData(self.cf, compress, self.data) 767 bin += compressed.compressed 768 769 f.write(bin) 770 771 return self 772 773 def to_c_array(self, 774 filename: str, 775 compress: CompressMethod = CompressMethod.NONE): 776 self._check_ext(filename, ".c") 777 self._check_dir(filename) 778 779 if compress != CompressMethod.NONE: 780 data = LVGLCompressData(self.cf, compress, self.data).compressed 781 else: 782 data = self.data 783 write_c_array_file(self.w, self.h, self.stride, self.cf, filename, 784 self.premultiplied, 785 compress, data) 786 787 def to_png(self, filename: str): 788 self._check_ext(filename, ".png") 789 self._check_dir(filename) 790 791 old_stride = self.stride 792 self.adjust_stride(align=1) 793 if self.cf.is_indexed: 794 data = self.data 795 # Separate lvgl bin image data to palette and bitmap 796 # The palette is in format of [(RGBA), (RGBA)...]. 797 # LVGL palette is in format of B,G,R,A,... 798 palette = [(data[i * 4 + 2], data[i * 4 + 1], data[i * 4 + 0], 799 data[i * 4 + 3]) for i in range(self.cf.ncolors)] 800 801 data = data[self.cf.ncolors * 4:] 802 803 encoder = png.Writer(self.w, 804 self.h, 805 palette=palette, 806 bitdepth=self.cf.bpp) 807 # separate packed data to plain data 808 data = unpack_colors(data, self.cf, self.w) 809 elif self.cf.is_alpha_only: 810 # separate packed data to plain data 811 transparency = unpack_colors(self.data, self.cf, self.w) 812 data = [] 813 for a in transparency: 814 data += [0, 0, 0, a] 815 encoder = png.Writer(self.w, self.h, greyscale=False, alpha=True) 816 elif self.cf == ColorFormat.L8: 817 # to grayscale 818 encoder = png.Writer(self.w, 819 self.h, 820 bitdepth=self.cf.bpp, 821 greyscale=True, 822 alpha=False) 823 data = self.data 824 elif self.cf.is_colormap: 825 encoder = png.Writer(self.w, 826 self.h, 827 alpha=self.cf.has_alpha, 828 greyscale=False) 829 data = unpack_colors(self.data, self.cf, self.w) 830 else: 831 logging.warning(f"missing logic: {self.cf.name}") 832 return 833 834 with open(filename, "wb") as f: 835 encoder.write_array(f, data) 836 837 self.adjust_stride(stride=old_stride) 838 839 def from_png(self, 840 filename: str, 841 cf: ColorFormat = None, 842 background: int = 0x00_00_00, 843 rgb565_dither=False): 844 """ 845 Create lvgl image from png file. 846 If cf is none, used I1/2/4/8 based on palette size 847 """ 848 849 self.background = background 850 self.rgb565_dither = rgb565_dither 851 852 if cf is None: # guess cf from filename 853 # split filename string and match with ColorFormat to check 854 # which cf to use 855 names = str(path.basename(filename)).split(".") 856 for c in names[1:-1]: 857 if c in ColorFormat.__members__: 858 cf = ColorFormat[c] 859 break 860 861 if cf is None or cf.is_indexed: # palette mode 862 self._png_to_indexed(cf, filename) 863 elif cf.is_alpha_only: 864 self._png_to_alpha_only(cf, filename) 865 elif cf.is_luma_only: 866 self._png_to_luma_only(cf, filename) 867 elif cf.is_colormap: 868 self._png_to_colormap(cf, filename) 869 else: 870 logging.warning(f"missing logic: {cf.name}") 871 872 logging.info(f"from png: {filename}, cf: {self.cf.name}") 873 return self 874 875 def _png_to_indexed(self, cf: ColorFormat, filename: str): 876 # convert to palette mode 877 auto_cf = cf is None 878 879 # read the image data to get the metadata 880 reader = png.Reader(filename=filename) 881 w, h, rows, metadata = reader.read() 882 883 # to preserve original palette data only convert the image if needed. For this 884 # check if image has a palette and the requested palette size equals the existing one 885 if not 'palette' in metadata or not auto_cf and len(metadata['palette']) != 2 ** cf.bpp: 886 # reread and convert file 887 reader = png.Reader( 888 bytes=PngQuant(256 if auto_cf else cf.ncolors).convert(filename)) 889 w, h, rows, _ = reader.read() 890 891 palette = reader.palette(alpha="force") # always return alpha 892 893 palette_len = len(palette) 894 if auto_cf: 895 if palette_len <= 2: 896 cf = ColorFormat.I1 897 elif palette_len <= 4: 898 cf = ColorFormat.I2 899 elif palette_len <= 16: 900 cf = ColorFormat.I4 901 else: 902 cf = ColorFormat.I8 903 904 if palette_len != cf.ncolors: 905 if not auto_cf: 906 logging.warning( 907 f"{path.basename(filename)} palette: {palette_len}, " 908 f"extended to: {cf.ncolors}") 909 palette += [(255, 255, 255, 0)] * (cf.ncolors - palette_len) 910 911 # Assemble lvgl image palette from PNG palette. 912 # PNG palette is a list of tuple(R,G,B,A) 913 914 rawdata = bytearray() 915 for (r, g, b, a) in palette: 916 rawdata += uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) 917 918 # pack data if not in I8 format 919 if cf == ColorFormat.I8: 920 for e in rows: 921 rawdata += e 922 else: 923 for e in png.pack_rows(rows, cf.bpp): 924 rawdata += e 925 926 self.set_data(cf, w, h, rawdata) 927 928 def _png_to_alpha_only(self, cf: ColorFormat, filename: str): 929 reader = png.Reader(str(filename)) 930 w, h, rows, info = reader.asRGBA8() 931 if not info['alpha']: 932 raise FormatError(f"{filename} has no alpha channel") 933 934 rawdata = bytearray() 935 if cf == ColorFormat.A8: 936 for row in rows: 937 A = row[3::4] 938 for e in A: 939 rawdata += uint8_t(e) 940 else: 941 shift = 8 - cf.bpp 942 mask = 2**cf.bpp - 1 943 rows = [[(a >> shift) & mask for a in row[3::4]] for row in rows] 944 for row in png.pack_rows(rows, cf.bpp): 945 rawdata += row 946 947 self.set_data(cf, w, h, rawdata) 948 949 def sRGB_to_linear(self, x): 950 if x < 0.04045: 951 return x / 12.92 952 return pow((x + 0.055) / 1.055, 2.4) 953 954 def linear_to_sRGB(self, y): 955 if y <= 0.0031308: 956 return 12.92 * y 957 return 1.055 * pow(y, 1 / 2.4) - 0.055 958 959 def _png_to_luma_only(self, cf: ColorFormat, filename: str): 960 reader = png.Reader(str(filename)) 961 w, h, rows, info = reader.asRGBA8() 962 rawdata = bytearray() 963 for row in rows: 964 R = row[0::4] 965 G = row[1::4] 966 B = row[2::4] 967 A = row[3::4] 968 for r, g, b, a in zip(R, G, B, A): 969 r, g, b, a = color_pre_multiply(r, g, b, a, self.background) 970 r = self.sRGB_to_linear(r / 255.0) 971 g = self.sRGB_to_linear(g / 255.0) 972 b = self.sRGB_to_linear(b / 255.0) 973 luma = 0.2126 * r + 0.7152 * g + 0.0722 * b 974 rawdata += uint8_t(int(self.linear_to_sRGB(luma) * 255)) 975 976 self.set_data(ColorFormat.L8, w, h, rawdata) 977 978 def _png_to_colormap(self, cf, filename: str): 979 980 if cf == ColorFormat.ARGB8888: 981 982 def pack(r, g, b, a): 983 return uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) 984 elif cf == ColorFormat.XRGB8888: 985 986 def pack(r, g, b, a): 987 r, g, b, a = color_pre_multiply(r, g, b, a, self.background) 988 return uint32_t((0xff << 24) | (r << 16) | (g << 8) | (b << 0)) 989 elif cf == ColorFormat.RGB888: 990 991 def pack(r, g, b, a): 992 r, g, b, a = color_pre_multiply(r, g, b, a, self.background) 993 return uint24_t((r << 16) | (g << 8) | (b << 0)) 994 elif cf == ColorFormat.RGB565: 995 996 def pack(r, g, b, a): 997 r, g, b, a = color_pre_multiply(r, g, b, a, self.background) 998 color = (r >> 3) << 11 999 color |= (g >> 2) << 5 1000 color |= (b >> 3) << 0 1001 return uint16_t(color) 1002 1003 elif cf == ColorFormat.RGB565A8: 1004 1005 def pack(r, g, b, a): 1006 color = (r >> 3) << 11 1007 color |= (g >> 2) << 5 1008 color |= (b >> 3) << 0 1009 return uint16_t(color) 1010 elif cf == ColorFormat.ARGB8565: 1011 1012 def pack(r, g, b, a): 1013 color = (r >> 3) << 11 1014 color |= (g >> 2) << 5 1015 color |= (b >> 3) << 0 1016 return uint24_t((a << 16) | color) 1017 else: 1018 raise FormatError(f"Invalid color format: {cf.name}") 1019 1020 reader = png.Reader(str(filename)) 1021 w, h, rows, _ = reader.asRGBA8() 1022 rawdata = bytearray() 1023 alpha = bytearray() 1024 for y, row in enumerate(rows): 1025 R = row[0::4] 1026 G = row[1::4] 1027 B = row[2::4] 1028 A = row[3::4] 1029 for x, (r, g, b, a) in enumerate(zip(R, G, B, A)): 1030 if cf == ColorFormat.RGB565A8: 1031 alpha += uint8_t(a) 1032 1033 if ( 1034 self.rgb565_dither and 1035 cf in (ColorFormat.RGB565, ColorFormat.RGB565A8, ColorFormat.ARGB8565) 1036 ): 1037 treshold_id = ((y & 7) << 3) + (x & 7) 1038 1039 r = min(r + red_thresh[treshold_id], 0xFF) & 0xF8 1040 g = min(g + green_thresh[treshold_id], 0xFF) & 0xFC 1041 b = min(b + blue_thresh[treshold_id], 0xFF) & 0xF8 1042 1043 rawdata += pack(r, g, b, a) 1044 1045 if cf == ColorFormat.RGB565A8: 1046 rawdata += alpha 1047 1048 self.set_data(cf, w, h, rawdata) 1049 1050 1051red_thresh = [ 1052 1, 7, 3, 5, 0, 8, 2, 6, 1053 7, 1, 5, 3, 8, 0, 6, 2, 1054 3, 5, 0, 8, 2, 6, 1, 7, 1055 5, 3, 8, 0, 6, 2, 7, 1, 1056 0, 8, 2, 6, 1, 7, 3, 5, 1057 8, 0, 6, 2, 7, 1, 5, 3, 1058 2, 6, 1, 7, 3, 5, 0, 8, 1059 6, 2, 7, 1, 5, 3, 8, 0 1060] 1061 1062green_thresh = [ 1063 1, 3, 2, 2, 3, 1, 2, 2, 1064 2, 2, 0, 4, 2, 2, 4, 0, 1065 3, 1, 2, 2, 1, 3, 2, 2, 1066 2, 2, 4, 0, 2, 2, 0, 4, 1067 1, 3, 2, 2, 3, 1, 2, 2, 1068 2, 2, 0, 4, 2, 2, 4, 0, 1069 3, 1, 2, 2, 1, 3, 2, 2, 1070 2, 2, 4, 0, 2, 2, 0, 4 1071] 1072 1073blue_thresh = [ 1074 5, 3, 8, 0, 6, 2, 7, 1, 1075 3, 5, 0, 8, 2, 6, 1, 7, 1076 8, 0, 6, 2, 7, 1, 5, 3, 1077 0, 8, 2, 6, 1, 7, 3, 5, 1078 6, 2, 7, 1, 5, 3, 8, 0, 1079 2, 6, 1, 7, 3, 5, 0, 8, 1080 7, 1, 5, 3, 8, 0, 6, 2, 1081 1, 7, 3, 5, 0, 8, 2, 6 1082] 1083 1084 1085class RLEHeader: 1086 1087 def __init__(self, blksize: int, len: int): 1088 self.blksize = blksize 1089 self.len = len 1090 1091 @property 1092 def binary(self): 1093 magic = 0x5aa521e0 1094 1095 rle_header = self.blksize 1096 rle_header |= (self.len & 0xffffff) << 4 1097 1098 binary = bytearray() 1099 binary.extend(uint32_t(magic)) 1100 binary.extend(uint32_t(rle_header)) 1101 return binary 1102 1103 1104class RLEImage(LVGLImage): 1105 1106 def __init__(self, 1107 cf: ColorFormat = ColorFormat.UNKNOWN, 1108 w: int = 0, 1109 h: int = 0, 1110 data: bytes = b'') -> None: 1111 super().__init__(cf, w, h, data) 1112 1113 def to_rle(self, filename: str): 1114 """ 1115 Compress this image to file, filename should be ended with '.rle' 1116 """ 1117 self._check_ext(filename, ".rle") 1118 self._check_dir(filename) 1119 1120 # compress image data excluding lvgl image header 1121 blksize = (self.cf.bpp + 7) // 8 1122 compressed = self.rle_compress(self.data, blksize) 1123 with open(filename, "wb+") as f: 1124 header = RLEHeader(blksize, len(self.data)).binary 1125 header.extend(self.header.binary) 1126 f.write(header) 1127 f.write(compressed) 1128 1129 def rle_compress(self, data: bytearray, blksize: int, threshold=16): 1130 index = 0 1131 data_len = len(data) 1132 compressed_data = [] 1133 memview = memoryview(data) 1134 while index < data_len: 1135 repeat_cnt = self.get_repeat_count(memview[index:], blksize) 1136 if repeat_cnt == 0: 1137 # done 1138 break 1139 elif repeat_cnt < threshold: 1140 nonrepeat_cnt = self.get_nonrepeat_count( 1141 memview[index:], blksize, threshold) 1142 ctrl_byte = uint8_t(nonrepeat_cnt | 0x80) 1143 compressed_data.append(ctrl_byte) 1144 compressed_data.append(memview[index:index + 1145 nonrepeat_cnt * blksize]) 1146 index += nonrepeat_cnt * blksize 1147 else: 1148 ctrl_byte = uint8_t(repeat_cnt) 1149 compressed_data.append(ctrl_byte) 1150 compressed_data.append(memview[index:index + blksize]) 1151 index += repeat_cnt * blksize 1152 1153 return b"".join(compressed_data) 1154 1155 def get_repeat_count(self, data: bytearray, blksize: int): 1156 if len(data) < blksize: 1157 return 0 1158 1159 start = data[:blksize] 1160 index = 0 1161 repeat_cnt = 0 1162 value = 0 1163 1164 while index < len(data): 1165 value = data[index:index + blksize] 1166 1167 if value == start: 1168 repeat_cnt += 1 1169 if repeat_cnt == 127: # limit max repeat count to max value of signed char. 1170 break 1171 else: 1172 break 1173 index += blksize 1174 1175 return repeat_cnt 1176 1177 def get_nonrepeat_count(self, data: bytearray, blksize: int, threshold): 1178 if len(data) < blksize: 1179 return 0 1180 1181 pre_value = data[:blksize] 1182 1183 index = 0 1184 nonrepeat_count = 0 1185 1186 repeat_cnt = 0 1187 while True: 1188 value = data[index:index + blksize] 1189 if value == pre_value: 1190 repeat_cnt += 1 1191 if repeat_cnt > threshold: 1192 # repeat found. 1193 break 1194 else: 1195 pre_value = value 1196 nonrepeat_count += 1 + repeat_cnt 1197 repeat_cnt = 0 1198 if nonrepeat_count >= 127: # limit max repeat count to max value of signed char. 1199 nonrepeat_count = 127 1200 break 1201 1202 index += blksize # move to next position 1203 if index >= len(data): # data end 1204 nonrepeat_count += repeat_cnt 1205 break 1206 1207 return nonrepeat_count 1208 1209 1210class RAWImage(): 1211 ''' 1212 RAW image is an exception to LVGL image, it has color format of RAW or RAW_ALPHA. 1213 It has same image header as LVGL image, but the data is pure raw data from file. 1214 It does not support stride adjustment etc. features for LVGL image. 1215 It only supports convert an image to C array with RAW or RAW_ALPHA format. 1216 ''' 1217 CF_SUPPORTED = (ColorFormat.RAW, ColorFormat.RAW_ALPHA) 1218 1219 class NotSupported(NotImplementedError): 1220 pass 1221 1222 def __init__(self, 1223 cf: ColorFormat = ColorFormat.UNKNOWN, 1224 data: bytes = b'') -> None: 1225 self.cf = cf 1226 self.data = data 1227 1228 def to_c_array(self, 1229 filename: str): 1230 # Image size is set to zero, to let PNG or JPEG decoder to handle it 1231 # Stride is meaningless for RAW image 1232 write_c_array_file(0, 0, 0, self.cf, filename, 1233 False, CompressMethod.NONE, self.data) 1234 1235 def from_file(self, 1236 filename: str, 1237 cf: ColorFormat = None): 1238 if cf not in RAWImage.CF_SUPPORTED: 1239 raise RAWImage.NotSupported(f"Invalid color format: {cf.name}") 1240 1241 with open(filename, "rb") as f: 1242 self.data = f.read() 1243 self.cf = cf 1244 return self 1245 1246 1247class OutputFormat(Enum): 1248 C_ARRAY = "C" 1249 BIN_FILE = "BIN" 1250 PNG_FILE = "PNG" # convert to lvgl image and then to png 1251 1252 1253class PNGConverter: 1254 1255 def __init__(self, 1256 files: List, 1257 cf: ColorFormat, 1258 ofmt: OutputFormat, 1259 odir: str, 1260 background: int = 0x00, 1261 align: int = 1, 1262 premultiply: bool = False, 1263 compress: CompressMethod = CompressMethod.NONE, 1264 keep_folder=True, 1265 rgb565_dither=False) -> None: 1266 self.files = files 1267 self.cf = cf 1268 self.ofmt = ofmt 1269 self.output = odir 1270 self.pngquant = None 1271 self.keep_folder = keep_folder 1272 self.align = align 1273 self.premultiply = premultiply 1274 self.compress = compress 1275 self.background = background 1276 self.rgb565_dither = rgb565_dither 1277 1278 def _replace_ext(self, input, ext): 1279 if self.keep_folder: 1280 name, _ = path.splitext(input) 1281 else: 1282 name, _ = path.splitext(path.basename(input)) 1283 output = name + ext 1284 output = path.join(self.output, output) 1285 return output 1286 1287 def convert(self): 1288 output = [] 1289 for f in self.files: 1290 if self.cf in (ColorFormat.RAW, ColorFormat.RAW_ALPHA): 1291 # Process RAW image explicitly 1292 img = RAWImage().from_file(f, self.cf) 1293 img.to_c_array(self._replace_ext(f, ".c")) 1294 else: 1295 img = LVGLImage().from_png(f, self.cf, background=self.background, rgb565_dither=self.rgb565_dither) 1296 img.adjust_stride(align=self.align) 1297 1298 if self.premultiply: 1299 img.premultiply() 1300 output.append((f, img)) 1301 if self.ofmt == OutputFormat.BIN_FILE: 1302 img.to_bin(self._replace_ext(f, ".bin"), 1303 compress=self.compress) 1304 elif self.ofmt == OutputFormat.C_ARRAY: 1305 img.to_c_array(self._replace_ext(f, ".c"), 1306 compress=self.compress) 1307 elif self.ofmt == OutputFormat.PNG_FILE: 1308 img.to_png(self._replace_ext(f, ".png")) 1309 1310 return output 1311 1312 1313def main(): 1314 parser = argparse.ArgumentParser(description='LVGL PNG to bin image tool.') 1315 parser.add_argument('--ofmt', 1316 help="output filename format, C or BIN", 1317 default="BIN", 1318 choices=["C", "BIN", "PNG"]) 1319 parser.add_argument( 1320 '--cf', 1321 help=("bin image color format, use AUTO for automatically " 1322 "choose from I1/2/4/8"), 1323 default="I8", 1324 choices=[ 1325 "L8", "I1", "I2", "I4", "I8", "A1", "A2", "A4", "A8", "ARGB8888", 1326 "XRGB8888", "RGB565", "RGB565A8", "ARGB8565", "RGB888", "AUTO", 1327 "RAW", "RAW_ALPHA" 1328 ]) 1329 1330 parser.add_argument('--rgb565dither', action='store_true', 1331 help="use dithering to correct banding in gradients", default=False) 1332 1333 parser.add_argument('--premultiply', action='store_true', 1334 help="pre-multiply color with alpha", default=False) 1335 1336 parser.add_argument('--compress', 1337 help=("Binary data compress method, default to NONE"), 1338 default="NONE", 1339 choices=["NONE", "RLE", "LZ4"]) 1340 1341 parser.add_argument('--align', 1342 help="stride alignment in bytes for bin image", 1343 default=1, 1344 type=int, 1345 metavar='byte', 1346 nargs='?') 1347 parser.add_argument('--background', 1348 help="Background color for formats without alpha", 1349 default=0x00_00_00, 1350 type=lambda x: int(x, 0), 1351 metavar='color', 1352 nargs='?') 1353 parser.add_argument('-o', 1354 '--output', 1355 default="./output", 1356 help="Select the output folder, default to ./output") 1357 parser.add_argument('-v', '--verbose', action='store_true') 1358 parser.add_argument( 1359 'input', help="the filename or folder to be recursively converted") 1360 1361 args = parser.parse_args() 1362 1363 if path.isfile(args.input): 1364 files = [args.input] 1365 elif path.isdir(args.input): 1366 files = list(Path(args.input).rglob("*.[pP][nN][gG]")) 1367 else: 1368 raise BaseException(f"invalid input: {args.input}") 1369 1370 if args.verbose: 1371 logging.basicConfig(level=logging.INFO) 1372 1373 logging.info(f"options: {args.__dict__}, files:{[str(f) for f in files]}") 1374 1375 if args.cf == "AUTO": 1376 cf = None 1377 else: 1378 cf = ColorFormat[args.cf] 1379 1380 ofmt = OutputFormat(args.ofmt) if cf not in ( 1381 ColorFormat.RAW, ColorFormat.RAW_ALPHA) else OutputFormat.C_ARRAY 1382 compress = CompressMethod[args.compress] 1383 1384 converter = PNGConverter(files, 1385 cf, 1386 ofmt, 1387 args.output, 1388 background=args.background, 1389 align=args.align, 1390 premultiply=args.premultiply, 1391 compress=compress, 1392 keep_folder=False, 1393 rgb565_dither=args.rgb565dither) 1394 output = converter.convert() 1395 for f, img in output: 1396 logging.info(f"len: {img.data_len} for {path.basename(f)} ") 1397 1398 print(f"done {len(files)} files") 1399 1400 1401def test(): 1402 logging.basicConfig(level=logging.INFO) 1403 f = "pngs/cogwheel.RGB565A8.png" 1404 img = LVGLImage().from_png(f, 1405 cf=ColorFormat.ARGB8565, 1406 background=0xFF_FF_00, 1407 rgb565_dither=True) 1408 img.adjust_stride(align=16) 1409 img.premultiply() 1410 img.to_bin("output/cogwheel.ARGB8565.bin") 1411 img.to_c_array("output/cogwheel-abc.c") # file name is used as c var name 1412 img.to_png("output/cogwheel.ARGB8565.png.png") # convert back to png 1413 1414 1415def test_raw(): 1416 logging.basicConfig(level=logging.INFO) 1417 f = "pngs/cogwheel.RGB565A8.png" 1418 img = RAWImage().from_file(f, 1419 cf=ColorFormat.RAW_ALPHA) 1420 img.to_c_array("output/cogwheel-raw.c") 1421 1422 1423if __name__ == "__main__": 1424 # test() 1425 # test_raw() 1426 main() 1427