playscii/playscii/palette.py

292 lines
11 KiB
Python

import math
import os.path
import time
from random import randint
from PIL import Image
from .lab_color import lab_color_diff, rgb_to_lab
from .texture import Texture
PALETTE_DIR = "palettes/"
PALETTE_EXTENSIONS = ["png", "gif", "bmp"]
MAX_COLORS = 1024
class PaletteLord:
# time in ms between checks for hot reload
hot_reload_check_interval = 2 * 1000
def __init__(self, app):
self.app = app
self.last_check = 0
def check_hot_reload(self):
if (
self.app.get_elapsed_time() - self.last_check
< self.hot_reload_check_interval
):
return
self.last_check = self.app.get_elapsed_time()
for palette in self.app.palettes:
if palette.has_updated():
try:
palette.load_image()
self.app.log(f"PaletteLord: success reloading {palette.filename}")
except Exception:
self.app.log(
f"PaletteLord: failed reloading {palette.filename}",
True,
)
class Palette:
def __init__(self, app, src_filename, log):
self.init_success = False
self.app = app
self.filename = self.app.find_filename_path(
src_filename, PALETTE_DIR, PALETTE_EXTENSIONS
)
if self.filename is None:
self.app.log(f"Couldn't find palette image {src_filename}")
return
self.last_image_change = os.path.getmtime(self.filename)
self.name = os.path.basename(self.filename)
self.name = os.path.splitext(self.name)[0]
self.load_image()
self.base_filename = os.path.splitext(os.path.basename(self.filename))[0]
if log and not self.app.game_mode:
self.app.log(f"loaded palette '{self.name}' from {self.filename}:")
self.app.log(f" unique colors found: {int(len(self.colors) - 1)}")
self.app.log(f" darkest color index: {self.darkest_index}")
self.app.log(f" lightest color index: {self.lightest_index}")
self.init_success = True
def load_image(self):
"loads palette data from the given bitmap image"
src_img = Image.open(self.filename)
src_img = src_img.convert("RGBA")
width, height = src_img.size
# store texture for chooser preview etc
self.src_texture = Texture(src_img.tobytes(), width, height)
# scan image L->R T->B for unique colors, store em as tuples
# color 0 is always fully transparent
self.colors = [(0, 0, 0, 0)]
seen = {(0, 0, 0, 0)}
# determine lightest and darkest colors in palette for defaults
lightest = 0
darkest = 255 * 3 + 1
self.lightest_index, self.darkest_index = 0, 0
for y in range(height):
for x in range(width):
# bail if we've now read max colors
if len(self.colors) >= MAX_COLORS:
break
color = src_img.getpixel((x, y))
if color not in seen:
seen.add(color)
self.colors.append(color)
# is this lightest/darkest unique color so far? save index
luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07
if luminosity < darkest:
darkest = luminosity
self.darkest_index = len(self.colors) - 1
elif luminosity > lightest:
lightest = luminosity
self.lightest_index = len(self.colors) - 1
# create new 1D image with unique colors
img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0))
x = 0
for color in self.colors:
img.putpixel((x, 0), color)
x += 1
# debug: save out generated palette texture
# img.save('palette.png')
self.texture = Texture(img.tobytes(), MAX_COLORS, 1)
def has_updated(self):
"return True if source image file has changed since last check"
changed = os.path.getmtime(self.filename) > self.last_image_change
if changed:
self.last_image_change = time.time()
return changed
def generate_image(self):
width = min(16, len(self.colors) - 1)
height = math.floor((len(self.colors) - 1) / width)
# new PIL image, blank (0 alpha) pixels
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
# set each pixel from color list (minus first, transparent color)
color_index = 1
for y in range(height):
for x in range(width):
if color_index > len(self.colors) - 1:
break
img.putpixel((x, y), self.colors[color_index])
color_index += 1
return img
def export_as_image(self):
img = self.generate_image()
block_size = 8
# scale up
width, height = img.size
img = img.resize(
(width * block_size, height * block_size), resample=Image.NEAREST
)
# write to file
img_filename = self.app.documents_dir + PALETTE_DIR + self.name + ".png"
img.save(img_filename)
def all_colors_opaque(self):
"returns True if we have any non-opaque (<1 alpha) colors"
for color in self.colors[1:]:
if color[3] < 255:
return False
return True
def get_random_non_palette_color(self):
"returns random color not in this palette, eg for 8-bit transparency"
def rand_byte():
return randint(0, 255)
# assume full alpha
r, g, b, a = rand_byte(), rand_byte(), rand_byte(), 255
while (r, g, b, a) in self.colors:
r, g, b = rand_byte(), rand_byte(), rand_byte()
return r, g, b, a
def get_palettized_image(
self, src_img, transparent_color=(0, 0, 0), force_no_transparency=False
):
"returns a copy of source image quantized to this palette"
pal_img = Image.new("P", (1, 1))
# source must be in RGB (no alpha) format
out_img = src_img.convert("RGB")
# Image.putpalette needs a flat tuple :/
colors = []
for color in self.colors:
# ignore alpha for palettized image output
for channel in color[:-1]:
colors.append(channel)
# user-defined color 0 in case we want to do 8-bit transparency
if not force_no_transparency:
colors[0:3] = transparent_color
# PIL will fill out <256 color palettes with bogus values :/
while len(colors) < MAX_COLORS * 3:
for _ in range(3):
colors.append(0)
# palette for PIL must be exactly 256 colors
colors = colors[: 256 * 3]
pal_img.putpalette(tuple(colors))
return out_img.quantize(palette=pal_img)
def are_colors_similar(self, color_index_a, palette_b, color_index_b, tolerance=50):
"""
returns True if color index A is similar to color index B from
another palette.
"""
color_a = self.colors[color_index_a]
color_b = palette_b.colors[color_index_b % len(palette_b.colors)]
r_diff = abs(color_a[0] - color_b[0])
g_diff = abs(color_a[1] - color_b[1])
b_diff = abs(color_a[2] - color_b[2])
return (r_diff + g_diff + b_diff) <= tolerance
def get_closest_color_index(self, r, g, b):
"returns index of closest color in this palette to given color (kinda slow?)"
closest_diff = 99999999999
closest_diff_index = -1
for i, color in enumerate(self.colors):
l1, a1, b1 = rgb_to_lab(r, g, b)
l2, a2, b2 = rgb_to_lab(*color[:3])
diff = lab_color_diff(l1, a1, b1, l2, a2, b2)
if diff < closest_diff:
closest_diff = diff
closest_diff_index = i
# print('%s is closest to input color %s' % (self.colors[closest_diff_index], (r, g, b)))
return closest_diff_index
def get_random_color_index(self):
# exclude transparent first index
return randint(1, len(self.colors))
class PaletteFromList(Palette):
"palette created from list of 3/4-tuple base-255 colors instead of image"
def __init__(self, app, src_color_list, log):
self.init_success = False
self.app = app
# generate a unique non-user-facing palette name
name = f"PaletteFromList_{time.time()}"
self.filename = self.name = self.base_filename = name
colors = []
for color in src_color_list:
# assume 1 alpha if not given
if len(color) == 3:
colors.append((color[0], color[1], color[2], 255))
else:
colors.append(color)
self.colors = [(0, 0, 0, 0)] + colors
lightest = 0
darkest = 255 * 3 + 1
for color in self.colors:
luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07
if luminosity < darkest:
darkest = luminosity
self.darkest_index = len(self.colors) - 1
elif luminosity > lightest:
lightest = luminosity
self.lightest_index = len(self.colors) - 1
# create texture
img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0))
x = 0
for color in self.colors:
img.putpixel((x, 0), color)
x += 1
self.texture = Texture(img.tobytes(), MAX_COLORS, 1)
if log and not self.app.game_mode:
self.app.log(f"generated new palette '{self.name}'")
self.app.log(f" unique colors: {int(len(self.colors) - 1)}")
self.app.log(f" darkest color index: {self.darkest_index}")
self.app.log(f" lightest color index: {self.lightest_index}")
def has_updated(self):
"No bitmap source for this type of palette, so no hot-reload"
return False
class PaletteFromFile(Palette):
def __init__(self, app, src_filename, palette_filename, colors=MAX_COLORS):
self.init_success = False
src_filename = app.find_filename_path(src_filename)
if not src_filename:
app.log(f"Couldn't find palette source image {src_filename}")
return
# dither source image, re-save it, use that as the source for a palette
src_img = Image.open(src_filename)
# method:
src_img = src_img.convert(
"P", None, Image.FLOYDSTEINBERG, Image.ADAPTIVE, colors
)
src_img = src_img.convert("RGBA")
# write converted source image with new filename
# snip path & extension if it has em
palette_filename = os.path.basename(palette_filename)
palette_filename = os.path.splitext(palette_filename)[0]
# get most appropriate path for palette image
palette_path = app.get_dirnames(PALETTE_DIR, False)[0]
# if new filename exists, add a number to avoid overwriting
if os.path.exists(palette_path + palette_filename + ".png"):
i = 0
while os.path.exists(f"{palette_path}{palette_filename}{str(i)}.png"):
i += 1
palette_filename += str(i)
# (re-)add path and PNG extension
palette_filename = palette_path + palette_filename + ".png"
src_img.save(palette_filename)
# create the actual palette and export it as an image
Palette.__init__(self, app, palette_filename, True)
self.export_as_image()