diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 46e2facb..e4ad5c4f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update -qq \ make \ python3 \ python3-pip \ + python3-pil \ tar \ unzip \ wget \ diff --git a/doc/buildAndProgram.md b/doc/buildAndProgram.md index 29b91076..3b4ed22c 100644 --- a/doc/buildAndProgram.md +++ b/doc/buildAndProgram.md @@ -42,7 +42,7 @@ CMake configures the project according to variables you specify the command line **NRF5_SDK_PATH**|path to the NRF52 SDK|`-DNRF5_SDK_PATH=/home/jf/nrf52/Pinetime/sdk`| **CMAKE_BUILD_TYPE (\*)**| Build type (Release or Debug). Release is applied by default if this variable is not specified.|`-DCMAKE_BUILD_TYPE=Debug` **BUILD_DFU (\*\*)**|Build DFU files while building (needs [adafruit-nrfutil](https://github.com/adafruit/Adafruit_nRF52_nrfutil)).|`-DBUILD_DFU=1` -**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` +**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` **TARGET_DEVICE**|Target device, used for hardware configuration. Allowed: `PINETIME, MOY-TFK5, MOY-TIN5, MOY-TON5, MOY-UNK`|`-DTARGET_DEVICE=PINETIME` (Default) #### (\*) Note about **CMAKE_BUILD_TYPE** diff --git a/docker/Dockerfile b/docker/Dockerfile index 927160db..60556594 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update -qq \ make \ python3 \ python3-pip \ + python3-pil \ python-is-python3 \ tar \ unzip \ @@ -39,10 +40,6 @@ RUN pip3 install -Iv cryptography==3.3 RUN pip3 install cbor RUN npm i lv_font_conv@1.5.2 -g -RUN npm i ts-node@10.9.1 -g -RUN npm i @swc/core -g -RUN npm i lv_img_conv@0.3.0 -g - # build.sh knows how to compile COPY build.sh /opt/ diff --git a/src/resources/CMakeLists.txt b/src/resources/CMakeLists.txt index 0983aaff..3834e854 100644 --- a/src/resources/CMakeLists.txt +++ b/src/resources/CMakeLists.txt @@ -3,8 +3,8 @@ find_program(LV_FONT_CONV "lv_font_conv" NO_CACHE REQUIRED HINTS "${CMAKE_SOURCE_DIR}/node_modules/.bin") message(STATUS "Using ${LV_FONT_CONV} to generate font files") -find_program(LV_IMG_CONV "lv_img_conv" NO_CACHE REQUIRED - HINTS "${CMAKE_SOURCE_DIR}/node_modules/.bin") +find_program(LV_IMG_CONV "lv_img_conv.py" NO_CACHE REQUIRED + HINTS "${CMAKE_CURRENT_SOURCE_DIR}") message(STATUS "Using ${LV_IMG_CONV} to generate font files") if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.12) diff --git a/src/resources/generate-img.py b/src/resources/generate-img.py index cdbfc030..518d2206 100755 --- a/src/resources/generate-img.py +++ b/src/resources/generate-img.py @@ -11,6 +11,9 @@ import subprocess def gen_lvconv_line(lv_img_conv: str, dest: str, color_format: str, output_format: str, binary_format: str, sources: str): args = [lv_img_conv, sources, '--force', '--output-file', dest, '--color-format', color_format, '--output-format', output_format, '--binary-format', binary_format] + if lv_img_conv.endswith(".py"): + # lv_img_conv is a python script, call with current python executable + args = [sys.executable] + args return args diff --git a/src/resources/lv_img_conv.py b/src/resources/lv_img_conv.py new file mode 100755 index 00000000..04765462 --- /dev/null +++ b/src/resources/lv_img_conv.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +import argparse +import pathlib +import sys +import decimal +from PIL import Image + + +def classify_pixel(value, bits): + def round_half_up(v): + """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). + + 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 + """ + return int(decimal.Decimal(v).quantize(decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP)) + tmp = 1 << (8 - bits) + val = round_half_up(value / tmp) * tmp + if val < 0: + val = 0 + return val + + +def test_classify_pixel(): + # test difference between round() and round_half_up() + assert classify_pixel(18, 5) == 16 + # school rounding 4.5 to 5, but banker's rounding 4.5 to 4 + assert classify_pixel(18, 6) == 20 + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument("img", + help="Path to image to convert to C header file") + parser.add_argument("-o", "--output-file", + help="output file path (for single-image conversion)", + required=True) + parser.add_argument("-f", "--force", + help="allow overwriting the output file", + action="store_true") + parser.add_argument("-i", "--image-name", + help="name of image structure (not implemented)") + parser.add_argument("-c", "--color-format", + help="color format of image", + default="CF_TRUE_COLOR_ALPHA", + choices=[ + "CF_ALPHA_1_BIT", "CF_ALPHA_2_BIT", "CF_ALPHA_4_BIT", + "CF_ALPHA_8_BIT", "CF_INDEXED_1_BIT", "CF_INDEXED_2_BIT", "CF_INDEXED_4_BIT", + "CF_INDEXED_8_BIT", "CF_RAW", "CF_RAW_CHROMA", "CF_RAW_ALPHA", + "CF_TRUE_COLOR", "CF_TRUE_COLOR_ALPHA", "CF_TRUE_COLOR_CHROMA", "CF_RGB565A8", + ], + required=True) + parser.add_argument("-t", "--output-format", + help="output format of image", + default="bin", # default in original is 'c' + choices=["c", "bin"]) + parser.add_argument("--binary-format", + help="binary color format (needed if output-format is binary)", + default="ARGB8565_RBSWAP", + choices=["ARGB8332", "ARGB8565", "ARGB8565_RBSWAP", "ARGB8888"]) + parser.add_argument("-s", "--swap-endian", + help="swap endian of image (not implemented)", + action="store_true") + parser.add_argument("-d", "--dither", + help="enable dither (not implemented)", + action="store_true") + args = parser.parse_args() + + img_path = pathlib.Path(args.img) + out = pathlib.Path(args.output_file) + if not img_path.is_file(): + print(f"Input file is missing: '{args.img}'") + return 1 + print(f"Beginning conversion of {args.img}") + if out.exists(): + if args.force: + print(f"overwriting {args.output_file}") + else: + pritn(f"Error: refusing to overwrite {args.output_file} without -f specified.") + return 1 + out.touch() + + # only implemented the bare minimum, everything else is not implemented + if args.color_format not in ["CF_INDEXED_1_BIT", "CF_TRUE_COLOR_ALPHA"]: + raise NotImplementedError(f"argument --color-format '{args.color_format}' not implemented") + if args.output_format != "bin": + raise NotImplementedError(f"argument --output-format '{args.output_format}' not implemented") + if args.binary_format not in ["ARGB8565_RBSWAP", "ARGB8888"]: + raise NotImplementedError(f"argument --binary-format '{args.binary_format}' not implemented") + if args.image_name: + raise NotImplementedError(f"argument --image-name not implemented") + if args.swap_endian: + raise NotImplementedError(f"argument --swap-endian not implemented") + if args.dither: + raise NotImplementedError(f"argument --dither not implemented") + + # open image using Pillow + img = Image.open(img_path) + img_height = img.height + img_width = img.width + if args.color_format == "CF_TRUE_COLOR_ALPHA" and args.binary_format == "ARGB8888": + buf = bytearray(img_height*img_width*4) # 4 bytes (32 bit) per pixel + for y in range(img_height): + for x in range(img_width): + i = (y*img_width + x)*4 # buffer-index + pixel = img.getpixel((x,y)) + r, g, b, a = pixel + buf[i + 0] = r + buf[i + 1] = g + buf[i + 2] = b + buf[i + 3] = a + + elif args.color_format == "CF_TRUE_COLOR_ALPHA" and args.binary_format == "ARGB8565_RBSWAP": + buf = bytearray(img_height*img_width*3) # 3 bytes (24 bit) per pixel + for y in range(img_height): + for x in range(img_width): + i = (y*img_width + x)*3 # buffer-index + pixel = img.getpixel((x,y)) + r_act = classify_pixel(pixel[0], 5) + g_act = classify_pixel(pixel[1], 6) + b_act = classify_pixel(pixel[2], 5) + a = pixel[3] + r_act = min(r_act, 0xF8) + g_act = min(g_act, 0xFC) + b_act = min(b_act, 0xF8) + c16 = ((r_act) << 8) | ((g_act) << 3) | ((b_act) >> 3) # RGR565 + buf[i + 0] = (c16 >> 8) & 0xFF + buf[i + 1] = c16 & 0xFF + buf[i + 2] = a + + elif args.color_format == "CF_INDEXED_1_BIT": # ignore binary format, use color format as binary format + w = img_width >> 3 + if img_width & 0x07: + w+=1 + max_p = w * (img_height-1) + ((img_width-1) >> 3) + 8 # +8 for the palette + buf = bytearray(max_p+1) + + for y in range(img_height): + for x in range(img_width): + c, a = img.getpixel((x,y)) + p = w * y + (x >> 3) + 8 # +8 for the palette + buf[p] |= (c & 0x1) << (7 - (x & 0x7)) + # write palette information, for indexed-1-bit we need palette with two values + # write 8 palette bytes + buf[0] = 0 + buf[1] = 0 + buf[2] = 0 + buf[3] = 0 + # Normally there is much math behind this, but for the current use case this is close enough + # only needs to be more complicated if we have more than 2 colors in the palette + buf[4] = 255 + buf[5] = 255 + buf[6] = 255 + buf[7] = 255 + else: + # raise just to be sure + raise NotImplementedError(f"args.color_format '{args.color_format}' with args.binary_format '{args.binary_format}' not implemented") + + # write header + match args.color_format: + case "CF_TRUE_COLOR_ALPHA": + lv_cf = 5 + case "CF_INDEXED_1_BIT": + lv_cf = 7 + case _: + # raise just to be sure + raise NotImplementedError(f"args.color_format '{args.color_format}' not implemented") + header_32bit = lv_cf | (img_width << 10) | (img_height << 21) + buf_out = bytearray(4 + len(buf)) + buf_out[0] = header_32bit & 0xFF + buf_out[1] = (header_32bit & 0xFF00) >> 8 + buf_out[2] = (header_32bit & 0xFF0000) >> 16 + buf_out[3] = (header_32bit & 0xFF000000) >> 24 + buf_out[4:] = buf + + # write byte buffer to file + with open(out, "wb") as f: + f.write(buf_out) + return 0 + + +if __name__ == '__main__': + if "--test" in sys.argv: + # run small set of tests and exit + print("running tests") + test_classify_pixel() + print("success!") + sys.exit(0) + # run normal program + sys.exit(main())