pebble/tools/png2pblpng.py
2025-01-27 11:38:16 -08:00

249 lines
9.8 KiB
Python

#!/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 png
import itertools
import StringIO
import pebble_image_routines
# color reduction methods
TRUNCATE = "truncate"
NEAREST = "nearest"
COLOR_REDUCTION_CHOICES = [TRUNCATE, NEAREST]
SUPPORTED_PALETTES = ('pebble2', 'pebble64')
DEFAULT_COLOR_REDUCTION = NEAREST
# Public APIs
def convert_png_to_pebble_png(input_filename, output_filename,
palette_name, color_reduction_method=DEFAULT_COLOR_REDUCTION,
bitdepth=None):
"""
Convert a png to a pblpng and write it to output_filename
"""
output_png_writer, image_data = _convert_png_to_pebble_png_writer(
input_filename, palette_name, color_reduction_method, force_bitdepth=bitdepth)
with open(output_filename, 'wb') as output_file:
output_png_writer.write_array(output_file, image_data)
def convert_png_to_pebble_png_bytes(input_filename, palette_name,
color_reduction_method=DEFAULT_COLOR_REDUCTION,
bitdepth=None):
"""
Convert a png to a pblpng and return a string with the raw data
"""
output_png, image_data = _convert_png_to_pebble_png_writer(
input_filename, palette_name, color_reduction_method, force_bitdepth=bitdepth)
output_str = StringIO.StringIO()
output_png.write_array(output_str, image_data)
return output_str.getvalue()
# Implementation
def _convert_png_to_pebble_png_writer(input_filename, palette_name, color_reduction_method,
force_bitdepth=None):
input_png = png.Reader(filename=input_filename)
# sbit breaks pypngs convert_rgb_to_rgba routine
# and is unnecessary, as it is only an optional optimization
# so disable it by loading the PNG pre-data and disabling sbit
input_png.preamble()
input_png.sbit = None
# open as RGBA 32-bit (allows for simpler parsing cases)
width, height, pixels, metadata = input_png.asRGBA8()
# convert RGBA 32-bit boxed rows to list for output
rgba32_list = grouper(itertools.chain.from_iterable(pixels), 4)
color_reduction_func = pebble_image_routines.get_reduction_func(palette_name,
color_reduction_method)
is_grey, has_alpha, bitdepth, palette = get_palette_for_png(input_filename,
palette_name,
color_reduction_method)
if force_bitdepth is not None:
if bitdepth > force_bitdepth:
raise Exception("Tried to force {} bits; need at least {}."
.format(force_bitdepth, bitdepth))
# If we're forcing a particular bitdepth, and it's not the one we were going
# to use, skip the greyscale dance.
if bitdepth != force_bitdepth:
is_grey = False
bitdepth = force_bitdepth
transparent_grey = None
# determine the grey value for tRNs transparency
if is_grey and has_alpha:
if bitdepth == 4:
# 4 available shades of grey are occupied
transparent_grey = 0xC # bitdepth 4 supported value
else:
greyscale_list = [0, 255, 85, 170] # in order of bitdepth required
for lum in greyscale_list:
# find the first unused greyscale value in terms of available bitdepth
if (lum, lum, lum, 255) not in palette:
# transparent grey value for requested greyscale bitdepth
transparent_grey = lum >> (8 - bitdepth)
break
# second pass of pixel data, converts rgba32 pixels to greyscale or palettized output
image = []
for (r, g, b, a) in rgba32_list:
# operating on original pixel values, need to do the same color reduction
# as when the palette was generated
(r, g, b, a) = color_reduction_func(r, g, b, a)
if is_grey:
# convert red channel (as luminosity value) to a greyscale at bitdepth
# if transparent, output the transparent_grey value for that bitdepth
if a == 0:
image.append(transparent_grey)
else:
image.append(r >> (8 - bitdepth))
elif has_alpha:
# append the palette index for output
image.append(palette.index((r, g, b, a)))
else:
# append the palette index for output
image.append(palette.index((r, g, b)))
if is_grey:
# remove the palette for greyscale output with writer
palette = None
output_png = png.Writer(width=width, height=height, compression=9, bitdepth=bitdepth,
palette=palette, greyscale=is_grey, transparent=transparent_grey)
return (output_png, image)
def get_palette_for_png(input_filename, palette_name, color_reduction_method):
input_png = png.Reader(filename=input_filename)
# sbit breaks pypngs convert_rgb_to_rgba routine
# and is unnecessary, as it is only an optional optimization
# so disable it by loading the PNG pre-data and disabling sbit
input_png.preamble()
input_png.sbit = None
# open as RGBA 32-bit (allows for simpler parsing cases)
width, height, pixels, metadata = input_png.asRGBA8()
palette = [] # rgba32 image palette
is_grey = True # does the image only contain greyscale pixels (and only full or opaque)
has_alpha = False # does the image contain alpha
# iterators are one shot, so make a copy of just the iterator and not the data
# to be able to parse the data twice (we do not modify the pixel data itself)
# once to generate the palette
# once to output the final pixel data as greyscale or palette indexes
pixels, pixels2 = itertools.tee(pixels)
# Figure out what color reduction algorithm we should be using.
color_reduction_func = pebble_image_routines.get_reduction_func(palette_name,
color_reduction_method)
# convert RGBA 32-bit image colors to pebble color table
for (r, g, b, a) in grouper(itertools.chain.from_iterable(pixels2), 4):
(r, g, b, a) = color_reduction_func(r, g, b, a)
if (r, g, b, a) not in palette:
palette.append((r, g, b, a))
# Check if image contains any transparent pixels
if (a != 0xFF):
has_alpha = True
# greyscale only if rgb is gray and opaque or fully transparent
if is_grey and not (((r == g == b) and a == 255) or (r, g, b, a) == (0, 0, 0, 0)):
is_grey = False
# Calculate required bit depth
# get the bitdepth for the number of colors
bitdepth = pebble_image_routines.num_colors_to_bitdepth(len(palette))
if is_grey:
# for Greyscale, it is the required colors that set the bitdepth
# so if image contains LightGray or DarkGray it requires bitdepth 2
if (85, 85, 85, 255) in palette or (170, 170, 170, 255) in palette:
# if palette contains all 4 greyscale and transparent, bump up bitdepth
if (len(palette)) >= 5:
grey_bitdepth = 4
else:
grey_bitdepth = 2
else:
# if palette contains black, white and transparent, bump up bitdepth
if (len(palette)) >= 3:
grey_bitdepth = 2
else:
grey_bitdepth = 1
if grey_bitdepth > bitdepth:
is_grey = False
else:
bitdepth = grey_bitdepth
# update data for RGB output format
if not has_alpha:
# recreate the palette without an alpha channel to support RGB PNG
palette = [(p_r, p_g, p_b) for p_r, p_g, p_b, p_a in palette]
return is_grey, has_alpha, bitdepth, palette
def grouper(iterable, n, fillvalue=None):
from itertools import izip_longest
args = [iter(iterable)] * n
return izip_longest(fillvalue=fillvalue, *args)
def get_ideal_palette(is_color=False):
if is_color:
return 'pebble64'
else:
return 'pebble2'
def main():
import argparse
parser = argparse.ArgumentParser(
description='Convert PNG to 64-color palettized or grayscale PNG')
parser.add_argument('input_filename', type=str, help='png file to convert')
parser.add_argument('output_filename', type=str, help='converted file output')
parser.add_argument('--palette', type=str, required=False,
choices=SUPPORTED_PALETTES, default='pebble64',
help="Specify the standard palette of the resulting png. Colors will be "
"converted to this lower bit depth using the color_reduction_method "
"arg.")
parser.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))
args = parser.parse_args()
convert_png_to_pebble_png(args.input_filename, args.output_filename,
args.palette, args.color_reduction_method)
if __name__ == '__main__':
main()