#!python # -*- mode: python; Encoding: utf-8; coding: utf-8 -*- # Last updated: <2022/07/13 08:20:20 +0900> """ Yliluoma's ordered dithering algorithm 1, not tri-tone, very slow Arbitrary-palette positional dithering algorithm https://bisqwit.iki.fi/story/howto/dither/jy/ Usage: py ThisScript.py -i INPUT.png -o OUTPUT.png [-d num] [-p PALETTE] [--mp] -d num, --dither num : 2 or 4 or 8 (Dither 2x2, 4x4, 8x8) -p PALETTE, --palette PALETTE : Palette file (.png or .gpl) --mp : enable multi process Windows10 x64 21H2 + Python 3.9.13 64bit """ import os import sys from PIL import Image from tqdm import tqdm import argparse import re import concurrent.futures as cf dither_maps = { 2: [ [0, 2], [3, 1] ], 4: [ [0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5] ], 8: [ [0, 48, 12, 60, 3, 51, 15, 63], [32, 16, 44, 28, 35, 19, 47, 31], [8, 56, 4, 52, 11, 59, 7, 55], [40, 24, 36, 20, 43, 27, 39, 23], [2, 50, 14, 62, 1, 49, 13, 61], [34, 18, 46, 30, 33, 17, 45, 29], [10, 58, 6, 54, 9, 57, 5, 53], [42, 26, 38, 22, 41, 25, 37, 21] ] } pal = [ # (r, g, b) (8, 0, 0), (32, 26, 11), (67, 40, 23), (73, 41, 16), (35, 67, 9), (93, 79, 30), (156, 107, 32), (169, 34, 15), (43, 52, 124), (43, 116, 9), (208, 202, 64), (232, 160, 119), (106, 148, 171), (213, 196, 179), (252, 231, 110), (252, 250, 226) ] class Palette: """ Palette class. """ def __init__(self, filename): _, ext = os.path.splitext(filename) if ext == ".gpl": # GIMP palette file self.get_palette_from_gpl(filename) elif ext == ".png" or ext == ".gif": self.get_palette_from_image(filename) else: print("Error: Unsupported file = %s" % filename) sys.exit() def get_palette_from_gpl(self, filename): """ Get palette data from GIMP palette file (.gpl) """ with open(filename) as f: data = f.read() lst = data.splitlines() self.colors = [] self.palette = [] for s in lst: if re.match(r"^\s*#", s): continue if re.match("^GIMP Palette", s): continue m = re.match(r"^Name:\s*(.+)$", s) if m: # print("Palette name: %s" % m.groups()[0]) continue if re.match(r"^Columns", s): continue m = re.match(r"^\s*(\d+)\s+(\d+)\s+(\d+)", s) if m: r, g, b = m.groups() r = int(r) g = int(g) b = int(b) self.palette.append((r, g, b)) def get_palette_from_image(self, filename): """ Get palette data from image. """ im = Image.open(filename) w, h = im.size # print("# im.mode = %s" % im.mode) # print("%d x %d" % im.size) self.colors = [] self.palette = [] if im.mode == "P": # index color image cols = im.getcolors() p = im.getpalette() if p is None: print("Error: Not found palette.") sys.exit() for cnt, i in cols: r, g, b = p[i * 3], p[i * 3 + 1], p[i * 3 + 2] self.palette.append((r, g, b)) self.colors.append((cnt, (r, g, b))) elif im.mode == "RGB": # RGB image rgb_count = {} src = im.load() for y in range(h): for x in range(w): col = src[x, y] if col in rgb_count: rgb_count[col] += 1 else: rgb_count[col] = 1 self.palette = list(rgb_count.keys()) for c in self.palette: cnt = rgb_count[c] self.colors.append((cnt, c)) else: print("Error: Unsupported image mode = %s" % im.mode) sys.exit() def count(self): return sorted(self.colors, key=lambda x: x[0], reverse=True) def color_compare_ccir601(r1, g1, b1, r2, g2, b2): """ Compare the difference of two RGB values. weigh by CCIR 601 luminosity. """ luma1 = (r1 * 299 + g1 * 587 + b1 * 114) / (255 * 1000.0) luma2 = (r2 * 299 + g2 * 587 + b2 * 114) / (255 * 1000.0) lumad = luma1 - luma2 r = (r1 - r2) / 255.0 g = (g1 - g2) / 255.0 b = (b1 - b2) / 255.0 return (r * r * 0.299 + g * g * 0.587 + b * b * 0.114) * 0.75 + lumad * lumad def devise_best_mixing_plan(dt): r, g, b = dt["col"] d_range = dt["d_range"] pal = dt["pal"] r_cols = [0, 0] r_ratio = d_range // 2 least_penalty = 1e99 len_pal = len(pal) # Loop through every unique combination of two colors from the palette, # and through each possible way to mix those two colors. # They can be mixed in exactly 64 ways, when the threshold matrix is 8x8. for index1 in range(len_pal): for index2 in range(index1, len_pal, 1): for ratio in range(d_range): if index1 == index2 and ratio != 0: break # Determine the two component colors r1, g1, b1 = pal[index1] r2, g2, b2 = pal[index2] # Determine what mixing them in this proportion will produce r0 = r1 + ratio * (r2 - r1) / d_range g0 = g1 + ratio * (g2 - g1) / d_range b0 = b1 + ratio * (b2 - b1) / d_range # Determine how well that matches what we want to accomplish penalty = color_compare_ccir601(r, g, b, r0, g0, b0) \ + color_compare_ccir601(r1, g1, b1, r2, g2, b2) * 0.1 * \ (abs((ratio / d_range) - 0.5) + 0.5) if penalty < least_penalty: # Keep the result that has the smallest error least_penalty = penalty r_cols[0] = index1 r_cols[1] = index2 r_ratio = ratio i = 1 if dt["threshold"] < r_ratio else 0 return (dt["x"], dt["y"], pal[r_cols[i]]) def convert_dither(srcim, dither, pal, mp): w, h = srcim.size im = Image.new("RGB", (w, h)) src = srcim.load() dst = im.load() dmap = dither_maps[dither] dh = len(dmap) dw = len(dmap[0]) d_range = dw * dh # dither level range # algorithm 1 if mp: # multi process for y in tqdm(range(h), ascii=True): with cf.ProcessPoolExecutor(max_workers=None) as executor: futures = [] for x in range(w): dt = { "col": src[x, y], "x": x, "y": y, "d_range": d_range, "pal": pal, "threshold": dmap[y % dh][x % dw], } futures.append(executor.submit(devise_best_mixing_plan, dt)) for future in cf.as_completed(futures): _x, _y, _col = future.result() dst[_x, _y] = _col else: # single process for y in tqdm(range(h), ascii=True): for x in range(w): dt = { "col": src[x, y], "x": x, "y": y, "d_range": d_range, "pal": pal, "threshold": dmap[y % dh][x % dw], } _x, _y, _col = devise_best_mixing_plan(dt) dst[_x, _y] = _col return im def main(): parser = argparse.ArgumentParser(description="Yliluoma ordered dithering 1") parser.add_argument("-i", "--input", required=True, help="Input png filename") parser.add_argument("-o", "--output", required=True, help="Output png filename") parser.add_argument("-p", "--palette", help="Palette file (.png or .gpl)") parser.add_argument("-d", "--dither", type=int, default=8, help="Dither type 2,4,8 (2x2,4x4,8x8). default: 8") parser.add_argument("--mp", action="store_true", help="Enable multi process") args = parser.parse_args() if args.dither not in [2, 4, 8]: print("Error: Unknown dither = %d" % args.dither) sys.exit() if args.palette is not None: # load palette file # print("Palette file : %s" % args.palette) p = Palette(args.palette) palette = p.palette else: # print("Palette file is None") palette = pal srcim = Image.open(args.input) im = convert_dither(srcim=srcim, dither=args.dither, pal=palette, mp=args.mp) im.save(args.output) if __name__ == '__main__': main()