Skip to content

Commit 77546c9

Browse files
committed
lv_img_conv_py: minimal python port of node module
Create a minimal python port of the node.js module `lv_img_conv`. Only the currently in use color formats `CF_INDEXED_1_BIT` and `CF_TRUE_COLOR_ALPHA` are implemented. Output only as binary with format `ARGB8565_RBSWAP`. This is enough to create the `resources-1.13.0.zip`. Python3 implements "propper" "banker's rounding" by rounding to the nearest even number. Javascript rounds to the nearest integer. To have the same output as the original JavaScript implementation add a custom rounding function, which does "school" rounding (to the nearest integer) Update CMake file in `resources` folder to call `lv_img_conf.py` instead of node module. For docker-files install `python3-pil` package for `lv_img_conv.py` script. And remove the `lv_img_conv` node installation. --- gen_img: special handling for python lv_img_conv script Not needed on Linux systems, as the shebang of the python script is read and used. But just to be sure use the python interpreter found by CMake. Also helps if tried to run on Windows host. --- doc: buildAndProgram: remove node script lv_img_conv mention Remove node script `lv_img_conv` mention and replace it for runtime-depency `python3-pil` of python script `lv_img_conv.py`.
1 parent eac460f commit 77546c9

File tree

6 files changed

+201
-7
lines changed

6 files changed

+201
-7
lines changed

.devcontainer/Dockerfile

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ RUN apt-get update -qq \
1111
make \
1212
python3 \
1313
python3-pip \
14+
python3-pil \
1415
tar \
1516
unzip \
1617
wget \

doc/buildAndProgram.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ CMake configures the project according to variables you specify the command line
4242
**NRF5_SDK_PATH**|path to the NRF52 SDK|`-DNRF5_SDK_PATH=/home/jf/nrf52/Pinetime/sdk`|
4343
**CMAKE_BUILD_TYPE (\*)**| Build type (Release or Debug). Release is applied by default if this variable is not specified.|`-DCMAKE_BUILD_TYPE=Debug`
4444
**BUILD_DFU (\*\*)**|Build DFU files while building (needs [adafruit-nrfutil](https://github.com/adafruit/Adafruit_nRF52_nrfutil)).|`-DBUILD_DFU=1`
45-
**BUILD_RESOURCES (\*\*)**| Generate external resource while building (needs [lv_font_conv](https://github.com/lvgl/lv_font_conv) and [lv_img_conv](https://github.com/lvgl/lv_img_conv). |`-DBUILD_RESOURCES=1`
45+
**BUILD_RESOURCES (\*\*)**| Generate external resource while building (needs [lv_font_conv](https://github.com/lvgl/lv_font_conv) and [python3-pil/pillow](https://pillow.readthedocs.io) module). |`-DBUILD_RESOURCES=1`
4646
**TARGET_DEVICE**|Target device, used for hardware configuration. Allowed: `PINETIME, MOY-TFK5, MOY-TIN5, MOY-TON5, MOY-UNK`|`-DTARGET_DEVICE=PINETIME` (Default)
4747
4848
#### (\*) Note about **CMAKE_BUILD_TYPE**

docker/Dockerfile

+1-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ RUN apt-get update -qq \
1111
make \
1212
python3 \
1313
python3-pip \
14+
python3-pil \
1415
python-is-python3 \
1516
tar \
1617
unzip \
@@ -39,10 +40,6 @@ RUN pip3 install -Iv cryptography==3.3
3940
RUN pip3 install cbor
4041
RUN npm i lv_font_conv@1.5.2 -g
4142

42-
RUN npm i ts-node@10.9.1 -g
43-
RUN npm i @swc/core -g
44-
RUN npm i lv_img_conv@0.3.0 -g
45-
4643
# build.sh knows how to compile
4744
COPY build.sh /opt/
4845

src/resources/CMakeLists.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ find_program(LV_FONT_CONV "lv_font_conv" NO_CACHE REQUIRED
33
HINTS "${CMAKE_SOURCE_DIR}/node_modules/.bin")
44
message(STATUS "Using ${LV_FONT_CONV} to generate font files")
55

6-
find_program(LV_IMG_CONV "lv_img_conv" NO_CACHE REQUIRED
7-
HINTS "${CMAKE_SOURCE_DIR}/node_modules/.bin")
6+
find_program(LV_IMG_CONV "lv_img_conv.py" NO_CACHE REQUIRED
7+
HINTS "${CMAKE_CURRENT_SOURCE_DIR}")
88
message(STATUS "Using ${LV_IMG_CONV} to generate font files")
99

1010
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.12)

src/resources/generate-img.py

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
def gen_lvconv_line(lv_img_conv: str, dest: str, color_format: str, output_format: str, binary_format: str, sources: str):
1313
args = [lv_img_conv, sources, '--force', '--output-file', dest, '--color-format', color_format, '--output-format', output_format, '--binary-format', binary_format]
14+
if lv_img_conv.endswith(".py"):
15+
# lv_img_conv is a python script, call with current python executable
16+
args = [sys.executable] + args
1417

1518
return args
1619

src/resources/lv_img_conv.py

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import pathlib
4+
import sys
5+
import decimal
6+
from PIL import Image
7+
8+
9+
def classify_pixel(value, bits):
10+
def round_half_up(v):
11+
"""python3 implements "propper" "banker's rounding" by rounding to the nearest
12+
even number. Javascript rounds to the nearest integer.
13+
To have the same output as the original JavaScript implementation add a custom
14+
rounding function, which does "school" rounding (to the nearest integer).
15+
16+
see: https://stackoverflow.com/questions/43851273/how-to-round-float-0-5-up-to-1-0-while-still-rounding-0-45-to-0-0-as-the-usual
17+
"""
18+
return int(decimal.Decimal(v).quantize(decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP))
19+
tmp = 1 << (8 - bits)
20+
val = round_half_up(value / tmp) * tmp
21+
if val < 0:
22+
val = 0
23+
return val
24+
25+
26+
def test_classify_pixel():
27+
# test difference between round() and round_half_up()
28+
assert classify_pixel(18, 5) == 16
29+
# school rounding 4.5 to 5, but banker's rounding 4.5 to 4
30+
assert classify_pixel(18, 6) == 20
31+
32+
33+
def main():
34+
parser = argparse.ArgumentParser()
35+
36+
parser.add_argument("img",
37+
help="Path to image to convert to C header file")
38+
parser.add_argument("-o", "--output-file",
39+
help="output file path (for single-image conversion)",
40+
required=True)
41+
parser.add_argument("-f", "--force",
42+
help="allow overwriting the output file",
43+
action="store_true")
44+
parser.add_argument("-i", "--image-name",
45+
help="name of image structure (not implemented)")
46+
parser.add_argument("-c", "--color-format",
47+
help="color format of image",
48+
default="CF_TRUE_COLOR_ALPHA",
49+
choices=[
50+
"CF_ALPHA_1_BIT", "CF_ALPHA_2_BIT", "CF_ALPHA_4_BIT",
51+
"CF_ALPHA_8_BIT", "CF_INDEXED_1_BIT", "CF_INDEXED_2_BIT", "CF_INDEXED_4_BIT",
52+
"CF_INDEXED_8_BIT", "CF_RAW", "CF_RAW_CHROMA", "CF_RAW_ALPHA",
53+
"CF_TRUE_COLOR", "CF_TRUE_COLOR_ALPHA", "CF_TRUE_COLOR_CHROMA", "CF_RGB565A8",
54+
],
55+
required=True)
56+
parser.add_argument("-t", "--output-format",
57+
help="output format of image",
58+
default="bin", # default in original is 'c'
59+
choices=["c", "bin"])
60+
parser.add_argument("--binary-format",
61+
help="binary color format (needed if output-format is binary)",
62+
default="ARGB8565_RBSWAP",
63+
choices=["ARGB8332", "ARGB8565", "ARGB8565_RBSWAP", "ARGB8888"])
64+
parser.add_argument("-s", "--swap-endian",
65+
help="swap endian of image (not implemented)",
66+
action="store_true")
67+
parser.add_argument("-d", "--dither",
68+
help="enable dither (not implemented)",
69+
action="store_true")
70+
args = parser.parse_args()
71+
72+
img_path = pathlib.Path(args.img)
73+
out = pathlib.Path(args.output_file)
74+
if not img_path.is_file():
75+
print(f"Input file is missing: '{args.img}'")
76+
return 1
77+
print(f"Beginning conversion of {args.img}")
78+
if out.exists():
79+
if args.force:
80+
print(f"overwriting {args.output_file}")
81+
else:
82+
pritn(f"Error: refusing to overwrite {args.output_file} without -f specified.")
83+
return 1
84+
out.touch()
85+
86+
# only implemented the bare minimum, everything else is not implemented
87+
if args.color_format not in ["CF_INDEXED_1_BIT", "CF_TRUE_COLOR_ALPHA"]:
88+
raise NotImplementedError(f"argument --color-format '{args.color_format}' not implemented")
89+
if args.output_format != "bin":
90+
raise NotImplementedError(f"argument --output-format '{args.output_format}' not implemented")
91+
if args.binary_format not in ["ARGB8565_RBSWAP", "ARGB8888"]:
92+
raise NotImplementedError(f"argument --binary-format '{args.binary_format}' not implemented")
93+
if args.image_name:
94+
raise NotImplementedError(f"argument --image-name not implemented")
95+
if args.swap_endian:
96+
raise NotImplementedError(f"argument --swap-endian not implemented")
97+
if args.dither:
98+
raise NotImplementedError(f"argument --dither not implemented")
99+
100+
# open image using Pillow
101+
img = Image.open(img_path)
102+
img_height = img.height
103+
img_width = img.width
104+
if args.color_format == "CF_TRUE_COLOR_ALPHA" and args.binary_format == "ARGB8888":
105+
buf = bytearray(img_height*img_width*4) # 4 bytes (32 bit) per pixel
106+
for y in range(img_height):
107+
for x in range(img_width):
108+
i = (y*img_width + x)*4 # buffer-index
109+
pixel = img.getpixel((x,y))
110+
r, g, b, a = pixel
111+
buf[i + 0] = r
112+
buf[i + 1] = g
113+
buf[i + 2] = b
114+
buf[i + 3] = a
115+
116+
elif args.color_format == "CF_TRUE_COLOR_ALPHA" and args.binary_format == "ARGB8565_RBSWAP":
117+
buf = bytearray(img_height*img_width*3) # 3 bytes (24 bit) per pixel
118+
for y in range(img_height):
119+
for x in range(img_width):
120+
i = (y*img_width + x)*3 # buffer-index
121+
pixel = img.getpixel((x,y))
122+
r_act = classify_pixel(pixel[0], 5)
123+
g_act = classify_pixel(pixel[1], 6)
124+
b_act = classify_pixel(pixel[2], 5)
125+
a = pixel[3]
126+
r_act = min(r_act, 0xF8)
127+
g_act = min(g_act, 0xFC)
128+
b_act = min(b_act, 0xF8)
129+
c16 = ((r_act) << 8) | ((g_act) << 3) | ((b_act) >> 3) # RGR565
130+
buf[i + 0] = (c16 >> 8) & 0xFF
131+
buf[i + 1] = c16 & 0xFF
132+
buf[i + 2] = a
133+
134+
elif args.color_format == "CF_INDEXED_1_BIT": # ignore binary format, use color format as binary format
135+
w = img_width >> 3
136+
if img_width & 0x07:
137+
w+=1
138+
max_p = w * (img_height-1) + ((img_width-1) >> 3) + 8 # +8 for the palette
139+
buf = bytearray(max_p+1)
140+
141+
for y in range(img_height):
142+
for x in range(img_width):
143+
c, a = img.getpixel((x,y))
144+
p = w * y + (x >> 3) + 8 # +8 for the palette
145+
buf[p] |= (c & 0x1) << (7 - (x & 0x7))
146+
# write palette information, for indexed-1-bit we need palette with two values
147+
# write 8 palette bytes
148+
buf[0] = 0
149+
buf[1] = 0
150+
buf[2] = 0
151+
buf[3] = 0
152+
# Normally there is much math behind this, but for the current use case this is close enough
153+
# only needs to be more complicated if we have more than 2 colors in the palette
154+
buf[4] = 255
155+
buf[5] = 255
156+
buf[6] = 255
157+
buf[7] = 255
158+
else:
159+
# raise just to be sure
160+
raise NotImplementedError(f"args.color_format '{args.color_format}' with args.binary_format '{args.binary_format}' not implemented")
161+
162+
# write header
163+
match args.color_format:
164+
case "CF_TRUE_COLOR_ALPHA":
165+
lv_cf = 5
166+
case "CF_INDEXED_1_BIT":
167+
lv_cf = 7
168+
case _:
169+
# raise just to be sure
170+
raise NotImplementedError(f"args.color_format '{args.color_format}' not implemented")
171+
header_32bit = lv_cf | (img_width << 10) | (img_height << 21)
172+
buf_out = bytearray(4 + len(buf))
173+
buf_out[0] = header_32bit & 0xFF
174+
buf_out[1] = (header_32bit & 0xFF00) >> 8
175+
buf_out[2] = (header_32bit & 0xFF0000) >> 16
176+
buf_out[3] = (header_32bit & 0xFF000000) >> 24
177+
buf_out[4:] = buf
178+
179+
# write byte buffer to file
180+
with open(out, "wb") as f:
181+
f.write(buf_out)
182+
return 0
183+
184+
185+
if __name__ == '__main__':
186+
if "--test" in sys.argv:
187+
# run small set of tests and exit
188+
print("running tests")
189+
test_classify_pixel()
190+
print("success!")
191+
sys.exit(0)
192+
# run normal program
193+
sys.exit(main())

0 commit comments

Comments
 (0)