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