#!/usr/bin/env python # Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import StringIO import argparse import os import struct import sys import png import itertools import generate_c_byte_array from pebble_image_routines import (rgba32_triplet_to_argb8, num_colors_to_bitdepth, get_reduction_func) SCALE_TO_GCOLOR8 = 64 WHITE_COLOR_MAP = { 'white': 1, 'black': 0, 'transparent': 0, } BLACK_COLOR_MAP = { 'white': 0, 'black': 1, 'transparent': 0, } # translates bitdepth to supported PBI format #0 is special case of legacy 1Bit format bitdepth_dict = {0: 0, #GBitmapFormat1Bit 8: 1, #GBitmapFormat8Bit 1: 2, #GBitmapFormat1BitPalette 2: 3, #GBitmapFormat2BitPalette 4: 4} #GBitmapFormat4BitPalette FORMAT_BW = "bw" FORMAT_COLOR = "color" FORMAT_COLOR_RAW = "color_raw" # forces output to be ARGB8 (no palette) FORMAT_CHOICES = [FORMAT_BW, FORMAT_COLOR, FORMAT_COLOR_RAW] DEFAULT_FORMAT = FORMAT_BW TRUNCATE = "truncate" NEAREST = "nearest" COLOR_REDUCTION_CHOICES = [TRUNCATE, NEAREST] DEFAULT_COLOR_REDUCTION = NEAREST # Bitmap struct only contains a color palette for GBitmapFormat(1/2/4)BitPalette # Bitmap struct (NB: All fields are little-endian) # (uint16_t) row_size_bytes # (uint16_t) info_flags # bit 0 : is heap allocated (must be zero for bitmap files) # bits 1-5 : bitmap_format # bits 6-11 : reserved, must be 0 # bits 12-15 : file version # (int16_t) bounds.origin.x # (int16_t) bounds.origin.y # (int16_t) bounds.size.w # (int16_t) bounds.size.h # (uint8_t)[] image data (row_size_bytes-aligned, 0-padded rows of bits) # [optional] (uint8_t)[] argb8 palette data (0-padded to 2 ** bitdepth) class PebbleBitmap(object): def __init__(self, path, color_map=WHITE_COLOR_MAP, bitmap_format=DEFAULT_FORMAT, color_reduction_method=DEFAULT_COLOR_REDUCTION, crop=True, bitdepth=None, palette_name='pebble64'): self.palette_name = palette_name self.version = 1 self.path = path self.name, _ = os.path.splitext(os.path.basename(path)) self.color_map = color_map self.palette = None # only used in color mode for <=16 colors self.bitdepth = bitdepth # number of bits per pixel, 0 for legacy b&w if bitmap_format == FORMAT_BW: self.bitdepth = 0 self.bitmap_format = bitmap_format self.color_reduction_method = color_reduction_method width, height, pixels, metadata = png.Reader(filename=path).asRGBA8() # convert planar boxed row flat pixel to 2d array of (R, G, B, A) self._im_pixels = [] for row in pixels: row_list = [] for (r, g, b, a) in grouper(row, 4): row_list.append((r, g, b, a)) self._im_pixels.append(row_list) self._im_size = (width, height) self._set_bbox(crop) def _set_bbox(self, crop=True): left, top = (0, 0) right, bottom = self._im_size if crop: alphas = [[p[3] for p in row] for row in self._im_pixels] alphas_transposed = zip(*alphas) for row in alphas: if any(row): break top += 1 for row in reversed(alphas): if any(row): break bottom -= 1 for row in alphas_transposed: if any(row): break left += 1 for row in reversed(alphas_transposed): if any(row): break right -= 1 self.x = left self.y = top self.w = right - left self.h = bottom - top def row_size_bytes(self): """ Return the length of the bitmap's row in bytes. On b/w, row lengths are rounded up to the nearest word, padding up to 3 empty bytes per row. On color, row lengths are rounded up to the nearest byte """ if self.bitmap_format == FORMAT_COLOR_RAW: return self.w elif self.bitmap_format == FORMAT_COLOR: # adds (8 / bitdepth) - 1 to round up (ceil) to the next nearest byte return (self.w + ((8 / self.bitdepth) - 1)) / (8 / self.bitdepth) else: row_size_padded_words = (self.w + 31) / 32 return row_size_padded_words * 4 def info_flags(self): """Returns the type and version of bitmap.""" format_value = bitdepth_dict[self.bitdepth] return self.version << 12 | format_value << 1 def pbi_header(self): return struct.pack('<HHhhhh', self.row_size_bytes(), self.info_flags(), self.x, self.y, self.w, self.h) def image_bits_bw(self): """ Return a raw b/w bitmap capable of being rendered using Pebble's bitblt graphics routines. The returned bitmap will always be y * row_size_bytes large. """ def get_monochrome_value_for_pixel(pixel): if pixel[3] < 127: return self.color_map['transparent'] if ((pixel[0] + pixel[1] + pixel[2]) / 3) < 127: return self.color_map['black'] return self.color_map['white'] def pack_pixels_to_bitblt_word(pixels, x_offset, x_max): word = 0 for column in xrange(0, 32): x = x_offset + column if (x < x_max): pixel = pixels[x] word |= get_monochrome_value_for_pixel(pixel) << (column) return struct.pack('<I', word) src_pixels = self._im_pixels out_pixels = [] row_size_words = self.row_size_bytes() / 4 for row in xrange(self.y, self.y + self.h): x_max = self._im_size[0] for column_word in xrange(0, row_size_words): x_offset = self.x + column_word * 32 out_pixels.append(pack_pixels_to_bitblt_word(src_pixels[row], x_offset, x_max)) return ''.join(out_pixels) def image_bits_color(self): """ Return a raw color bitmap capable of being rendered using Pebble's bitblt graphics routines. """ if self.bitmap_format == FORMAT_COLOR_RAW: self.bitdepth = 8 # forced to 8-bit depth for color_raw, no palette else: self.generate_palette() assert self.bitdepth is not None out_pixels = [] for row in xrange(self.y, self.y + self.h): packed_count = 0 packed_value = 0 for column in xrange(self.x, self.x + self.w): pixel = self._im_pixels[row][column] r, g, b, a = [pixel[i] for i in range(4)] # convert RGBA 32-bit image colors to pebble color table fn = get_reduction_func(self.palette_name, self.color_reduction_method) r, g, b, a = fn(r, g, b, a) if a == 0: # clear values in transparent pixels r, g, b = (0, 0, 0) # convert colors to ARGB8 format argb8 = rgba32_triplet_to_argb8(r, g, b, a) if (self.bitdepth == 8): out_pixels.append(struct.pack("B", argb8)) else: # all palettized color bitdepths (1, 2, 4) # look up the color index in the palette color_index = self.palette.index(argb8) # shift and store the color index in a packed value packed_count = packed_count + 1 # pre-increment for calculation below packed_value = packed_value | (color_index << \ (self.bitdepth * (8 / self.bitdepth - (packed_count)))) if (packed_count == 8 / self.bitdepth): out_pixels.append(struct.pack("B", packed_value)) packed_count = 0 packed_value = 0 # write out the last non-byte-aligned set for the row (ie. byte-align rows) if (packed_count): out_pixels.append(struct.pack("B", packed_value)) return ''.join(out_pixels) def image_bits(self): if self.bitmap_format == FORMAT_COLOR or self.bitmap_format == FORMAT_COLOR_RAW: return self.image_bits_color() else: return self.image_bits_bw() def header(self): f = StringIO.StringIO() f.write("// GBitmap + pixel data generated by bitmapgen.py:\n\n") bytes = self.image_bits() bytes_var_name = "s_{var_name}_pixels".format(var_name=self.name) generate_c_byte_array.write(f, bytes, bytes_var_name) f.write("static const GBitmap s_{0}_bitmap = {{\n".format(self.name)) f.write(" .addr = (void*) &{0},\n".format(bytes_var_name)) f.write(" .row_size_bytes = {0},\n".format(self.row_size_bytes())) f.write(" .info_flags = 0x%02x,\n" % self.info_flags()) f.write(" .bounds = {\n") f.write(" .origin = {{ .x = {0}, .y = {1} }},\n".format(self.x, self.y)) f.write(" .size = {{ .w = {0}, .h = {1} }},\n".format(self.w, self.h)) f.write(" },\n") f.write("};\n\n") return f.getvalue() def convert_to_h(self, header_file=None): to_file = header_file if header_file else (os.path.splitext(self.path)[0] + '.h') with open(to_file, 'w') as f: f.write(self.header()) return to_file def convert_to_pbi(self): pbi_bits = [] image_data = self.image_bits() # compute before generating header pbi_bits.extend(self.pbi_header()) pbi_bits.extend(image_data) if self.palette and self.bitdepth < 8: # write out palette, padded to the bitdepth for i in xrange(0, 2**self.bitdepth): value = 0 if i < len(self.palette): value = self.palette[i] pbi_bits.extend(struct.pack('B', value)) return b"".join(pbi_bits) def convert_to_pbi_file(self, pbi_file=None): to_file = pbi_file if pbi_file else (os.path.splitext(self.path)[0] + '.pbi') with open(to_file, 'wb') as f: f.write(self.convert_to_pbi()) return to_file def generate_palette(self): self.palette = [] for row in xrange(self.y, self.y + self.h): for column in xrange(self.x, self.x + self.w): pixel = self._im_pixels[row][column] r, g, b, a = [pixel[i] for i in range(4)] # convert RGBA 32-bit image colors to pebble color table fn = get_reduction_func(self.palette_name, self.color_reduction_method) r, g, b, a = fn(r, g, b, a) if a == 0: # clear values in transparent pixels r, g, b = (0, 0, 0) # store color value as ARGB8 entry in the palette self.palette.append(rgba32_triplet_to_argb8(r, g, b, a)) # remove duplicate colors self.palette = list(set(self.palette)) # get the bitdepth for the number of colors min_bitdepth = num_colors_to_bitdepth(len(self.palette)) if self.bitdepth is None: self.bitdepth = min_bitdepth if self.bitdepth < min_bitdepth: raise Exception("Required bitdepth {} is lower than required depth {}." .format(self.bitdepth, min_bitdepth)) self.palette.extend([0] * (self.bitdepth - len(self.palette))) def cmd_pbi(args): pb = PebbleBitmap(args.input_png, bitmap_format=args.format, color_reduction_method=args.color_reduction_method, crop=not args.disable_crop) pb.convert_to_pbi_file(args.output_pbi) def cmd_header(args): pb = PebbleBitmap(args.input_png, bitmap_format=args.format, color_reduction_method=args.color_reduction_method, crop=not args.disable_crop) print pb.header() def cmd_white_trans_pbi(args): pb = PebbleBitmap(args.input_png, WHITE_COLOR_MAP, crop=not args.disable_crop) pb.convert_to_pbi_file(args.output_pbi) def cmd_black_trans_pbi(args): pb = PebbleBitmap(args.input_png, BLACK_COLOR_MAP, crop=not args.disable_crop) pb.convert_to_pbi_file(args.output_pbi) def process_all_bitmaps(): directory = "bitmaps" paths = [] for _, _, filenames in os.walk(directory): for filename in filenames: if os.path.splitext(filename)[1] == '.png': paths.append(os.path.join(directory, filename)) header_paths = [] for path in paths: b = PebbleBitmap(path) b.convert_to_pbi_file() to_file = b.convert_to_h() header_paths.append(os.path.basename(to_file)) f = open(os.path.join(directory, 'bitmaps.h'), 'w') print>> f, '#pragma once' for h in header_paths: print>> f, "#include \"{0}\"".format(h) f.close() def grouper(iterable, n, fillvalue=None): from itertools import izip_longest args = [iter(iterable)] * n return izip_longest(fillvalue=fillvalue, *args) def process_cmd_line_args(): parser = argparse.ArgumentParser(description="Generate pebble-usable files from png images") parser_parent = argparse.ArgumentParser(add_help=False) parser_parent.add_argument('--disable_crop', required=False, action='store_true', help='Disable transparent region cropping for PBI output') parser_parent.add_argument('--color_reduction_method', metavar='method', required=False, nargs=1, default=NEAREST, choices=COLOR_REDUCTION_CHOICES, help="Method used to convert colors to Pebble's color palette, " "options are [{}, {}]".format(NEAREST, TRUNCATE)) subparsers = parser.add_subparsers(help="commands", dest='which') bitmap_format = {"dest": "format", "metavar": "BITMAP_FORMAT", "choices": FORMAT_CHOICES, "nargs": "?", "default": DEFAULT_FORMAT, "help": "resulting GBitmap format"} input_png = {"dest": "input_png", "metavar": "INPUT_PNG", "help": "The png image to process"} output_pbi = {"dest": "output_pbi", "metavar": "OUTPUT_PBI", "help": "The pbi output file"} pbi_parser = subparsers.add_parser('pbi', parents=[parser_parent], help="make a .pbi (pebble binary image) file") for arg in [bitmap_format, input_png, output_pbi]: pbi_parser.add_argument(**arg) pbi_parser.set_defaults(func=cmd_pbi) h_parser = subparsers.add_parser('header', parents=[parser_parent], help="make a .h file") for arg in [bitmap_format, input_png]: h_parser.add_argument(**arg) h_parser.set_defaults(func=cmd_header) white_pbi_parser = subparsers.add_parser('white_trans_pbi', parents=[parser_parent], help="make a .pbi (pebble binary image) file for a white transparency layer") for arg in [input_png, output_pbi]: white_pbi_parser.add_argument(**arg) white_pbi_parser.set_defaults(func=cmd_white_trans_pbi) black_pbi_parser = subparsers.add_parser('black_trans_pbi', parents=[parser_parent], help="make a .pbi (pebble binary image) file for a black transparency layer") for arg in [input_png, output_pbi]: black_pbi_parser.add_argument(**arg) black_pbi_parser.set_defaults(func=cmd_black_trans_pbi) args = parser.parse_args() args.func(args) def main(): if (len(sys.argv) < 2): # process everything in the bitmaps folder process_all_bitmaps() else: # process an individual file process_cmd_line_args() if __name__ == "__main__": main()