playscii/image_convert.py

273 lines
12 KiB
Python

import math
import os.path
import time
import numpy as np
from PIL import Image
from lab_color import lab_color_diff, rgb_to_lab
from renderable_sprite import SpriteRenderable
"""
notes / future research
- generate a table of image sum vectors for every char/fg/bg combination in the charset + palette
- find the closest vector from this table for each source block
-- if it's a table, how to index?
- build dict of char index frequency, run each new source block comparison in order from most to least used (so far) characters.
-- takes same amount of time to try all characters, only helps if diffs use a "good-enough" threshold
-- threshold guesses: 0 = perfect, 600 = reasonable, 1000 = poor, 1500 crummy
"8088 corruption explained" talk:
https://www.youtube.com/watch?v=L6CkYou6hYU
- downsample each block bilinearly, divide each into 4x4 cells, then compare them with similarly bilinearly-downsampled char blocks
"""
class ImageConverter:
tiles_per_tick = 1
lab_color_comparison = True
# delay in seconds before beginning to convert tiles.
# lets eg UI catch up to BitmapImageImporter changes to Art.
start_delay = 1.0
def __init__(
self, app, image_filename, art, bicubic_scale=False, sequence_converter=None
):
self.init_success = False
image_filename = app.find_filename_path(image_filename)
if not image_filename or not os.path.exists(image_filename):
app.log(f"ImageConverter: Couldn't find image {image_filename}")
app.converter = None
return
self.app = app
self.start_time = time.time()
self.image_filename = image_filename
self.art = art
self.finished = False
# if an ImageSequenceConverter created us, keep a handle to it
self.sequence_converter = sequence_converter
try:
self.src_img = Image.open(self.image_filename).convert("RGB")
except Exception:
return
# if we're part of a sequence, app doesn't need handle directly to us
if not self.sequence_converter:
self.app.converter = self
# preserve aspect
self.char_w, self.char_h = art.charset.char_width, art.charset.char_height
art_pixel_w, art_pixel_h = self.char_w * art.width, self.char_h * art.height
w, h = self.src_img.size
ratio = min(art_pixel_h / h, art_pixel_w / w)
w = math.floor((w * ratio) / self.char_w) * self.char_w
h = math.floor((h * ratio) / self.char_h) * self.char_h
scale_method = Image.BICUBIC if bicubic_scale else Image.NEAREST
self.src_img = self.src_img.resize((w, h), resample=scale_method)
# convert source image to art's palette
self.src_img = self.art.palette.get_palettized_image(self.src_img)
self.color_diffs = self.get_generated_color_diffs(self.art.palette.colors)
# convert palettized source image to an array for fast comparisons
self.src_array = np.fromstring(self.src_img.tobytes(), dtype=np.uint8)
src_w, src_h = self.src_img.size
self.src_array = np.reshape(self.src_array, (src_h, src_w))
# convert charmap to 1-bit color for fast value swaps during
# block comparison
self.char_img = self.art.charset.image_data.copy().convert("RGB")
bw_pal_img = Image.new("P", (1, 1))
bw_pal = [0, 0, 0, 255, 255, 255]
while len(bw_pal) < 256 * 3:
bw_pal.append(0)
bw_pal_img.putpalette(tuple(bw_pal))
self.char_img = self.char_img.quantize(palette=bw_pal_img)
self.char_array = np.fromstring(self.char_img.tobytes(), dtype=np.uint8)
self.char_array = np.reshape(
self.char_array,
(self.art.charset.image_height, self.art.charset.image_width),
)
# create, size and position image preview
preview_img = self.src_img.copy()
# remove transparency if source image is a GIF to avoid a PIL crash :[
# TODO: https://github.com/python-pillow/Pillow/issues/1377
if "transparency" in preview_img.info:
preview_img.info.pop("transparency")
self.preview_sprite = SpriteRenderable(self.app, None, preview_img)
# preview image scale takes into account character aspect
self.preview_sprite.scale_x = w / (self.char_w / self.art.quad_width)
self.preview_sprite.scale_y = h / (self.char_h / self.art.quad_height)
# position in top left corner
self.preview_sprite.y = -self.preview_sprite.scale_y
self.preview_sprite.z = self.art.layers_z[self.art.active_layer] - 0.01
# clear active layer so we can see preview
self.art.clear_frame_layer(self.art.active_frame, self.art.active_layer, 0)
# block indices
self.x, self.y = 0, 0
# precompute block indices - lets us do a bit less + and *
self.char_blocks = []
for char_y in range(self.art.charset.map_height):
for char_x in range(self.art.charset.map_width):
x0, y0 = char_x * self.char_w, char_y * self.char_h
x1, y1 = x0 + self.char_w, y0 + self.char_h
self.char_blocks.append((x0, y0, x1, y1))
# characters might end mid-row, bail if so
if len(self.char_blocks) > self.art.charset.last_index:
break
self.init_success = True
def get_generated_color_diffs(self, colors):
# build table of color diffs
unique_colors = len(colors)
color_diffs = np.zeros((unique_colors, unique_colors), dtype=np.float32)
# option: L*a*b color space conversion for greater accuracy
get_color_diff = (
self.get_lab_color_diff
if self.lab_color_comparison
else self.get_rgb_color_diff
)
# get_color_diff = self.get_nonlinear_rgb_color_diff
for i, color in enumerate(colors):
for j, other_color in enumerate(colors):
color_diffs[i][j] = get_color_diff(color, other_color)
return color_diffs
def get_rgb_color_diff(self, color1, color2):
r = abs(color1[0] - color2[0])
g = abs(color1[1] - color2[1])
b = abs(color1[2] - color2[2])
a = abs(color1[3] - color2[3])
return abs(r + g + b + a)
def get_lab_color_diff(self, color1, color2):
l1, a1, b1 = rgb_to_lab(*color1[:3])
l2, a2, b2 = rgb_to_lab(*color2[:3])
return lab_color_diff(l1, a1, b1, l2, a2, b2)
def get_nonlinear_rgb_color_diff(self, color1, color2):
# from http://www.compuphase.com/cmetric.htm
rmean = int((color1[0] + color2[0]) / 2)
r = color1[0] - color2[0]
g = color1[1] - color2[1]
b = color1[2] - color2[2]
return math.sqrt(
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8)
)
def update(self):
if time.time() < self.start_time + self.start_delay:
return
for _ in range(self.tiles_per_tick):
x_start, y_start = self.x * self.char_w, self.y * self.char_h
x_end, y_end = x_start + self.char_w, y_start + self.char_h
block = self.src_array[y_start:y_end, x_start:x_end]
char, fg, bg = self.get_best_tile_for_block(block)
# get_best_etc sometimes returns 0 for darkest blocks,
# but transparency isn't properly supported yet
fg = self.art.palette.darkest_index if fg == 0 else fg
bg = self.art.palette.darkest_index if bg == 0 else bg
self.art.set_tile_at(
self.art.active_frame,
self.art.active_layer,
self.x,
self.y,
char,
fg,
bg,
)
# print('set block %s,%s to ch %s fg %s bg %s' % (self.x, self.y, char, fg, bg))
self.x += 1
if self.x >= self.art.width:
self.x = 0
self.y += 1
if self.y >= self.art.height:
self.finish()
break
def get_color_combos_for_block(self, src_block):
"""
returns # of unique colors, AND
list of unique (fg, bg) color index tuples for given block
"""
# get unique colors in source block
colors, counts = np.unique(src_block, False, False, return_counts=True)
# early out for single-color blocks
if len(colors) == 1:
return colors, []
# sort by most to least used colors
color_counts = []
for i, color in enumerate(colors):
color_counts += [(color, counts[i])]
color_counts.sort(key=lambda item: item[1], reverse=True)
combos = []
for color1, _count1 in color_counts:
for color2, _count2 in color_counts:
if color1 == color2:
continue
# fg/bg color swap SHOULD be allowed
if (color1, color2) in combos:
continue
combos.append((color1, color2))
return colors, combos
def get_best_tile_for_block(self, src_block):
"returns a (char, fg, bg) tuple for the best match of given block"
colors, combos = self.get_color_combos_for_block(src_block)
# single color block?
if len(combos) == 0:
# if a block is all transparent pixels or outside image bounds,
# no unique colors will be found
bg = 0 if len(colors) == 0 else colors[0]
return (0, 0, bg)
# compare all combos + chars w/ source block
best_char = 0
best_diff = 9999999999999
best_fg, best_bg = 0, 0
for bg, fg in combos:
# reset char index before each run through charset
char_index = 0
char_array = self.char_array.copy()
# replace 1-bit color of char image with fg and bg colors
char_array[char_array == 0] = bg
char_array[char_array == 1] = fg
for x0, y0, x1, y1 in self.char_blocks:
char_block = char_array[y0:y1, x0:x1]
# using array of difference values w/ fancy numpy indexing,
# sum() it
# (used to be get_block_diff(block1, block2))
diff = self.color_diffs[src_block, char_block].sum()
# no difference = return immediately
if diff == 0:
return (char_index, fg, bg)
if diff < best_diff:
best_diff = diff
best_char = char_index
best_fg, best_bg = fg, bg
# print('%s is new best char index, diff %s:' % (char_index, diff))
char_index += 1
# return best (least different to source block) char/fg/bg found
# print('%s is best char index, diff %s:' % (best_char, best_diff))
return (best_char, best_fg, best_bg)
def print_block(self, block, fg, bg):
"prints ASCII representation of a block with . and # as white and black"
w, h = block.shape
s = ""
for y in range(h):
for x in range(w):
if block[y][x] == fg:
s += "#"
else:
s += "."
s += "\n"
print(s)
def finish(self, cancelled=False):
self.finished = True
if not self.sequence_converter:
time_taken = time.time() - self.start_time
verb = "cancelled" if cancelled else "finished"
self.app.log(
f"Conversion of image {self.image_filename} {verb} after {time_taken:.3f} seconds"
)
self.app.converter = None
self.preview_sprite = None
self.app.update_window_title()