From e8315e6fe681aed0c23b284786fc32e9a349a5da Mon Sep 17 00:00:00 2001 From: Chris Mounce Date: Sun, 30 Oct 2022 15:57:11 -0700 Subject: [PATCH] Implement two-tone for 8x8 blocks of an image --- peacock-bayer.png | Bin 0 -> 5291 bytes two-tone.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 peacock-bayer.png create mode 100644 two-tone.py diff --git a/peacock-bayer.png b/peacock-bayer.png new file mode 100644 index 0000000000000000000000000000000000000000..eda2a59d99d7415ca79d70e348ada60c7807472e GIT binary patch literal 5291 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 int: + result = 0 + for i in range(3): + result += (a[i] - b[i])**2 + return result + +def nearest_color(color: RGB, colors: list[RGB]) -> RGB: + return min(colors, key=lambda c: distance_sq(c, color)) + +def choose_color_pair_kmeans(counts: dict[RGB,int]) -> tuple[RGB, RGB]: + colors = [k for k, v in counts.items() if v > 0] + total_count = sum(counts.values()) + if len(colors) == 0: + raise ValueError('no colors present') + elif len(colors) == 1: + return colors[0], None + + def weighted_avg(colors: list[RGB]) -> RGB: + result = [0, 0, 0] + for color in colors: + weight = counts[color] + for i in range(3): + result[i] += weight*color[i] + return tuple(round(x/total_count) for x in result) + + # k-means to choose the two colors + old_result = (None, None) + result = (darkest_color, lightest_color) + while True: + lists = ([], []) + for color in colors: + d_dark = distance_sq(color, result[0]) + d_light = distance_sq(color, result[1]) + if d_dark < d_light: + lists[0].append(color) + else: + lists[1].append(color) + old_result = result + result = tuple(weighted_avg(cs) for cs in lists) + if old_result == result: + break + return tuple(nearest_color(x, palette) for x in result) + + +def choose_color_pair_luminance(counts: dict[RGB, int]) -> tuple[RGB,RGB]: + # Calculate brightness threshold + mean_brightness = 0 + total_count = 0 + for rgb, count in counts.items(): + mean_brightness += brightness(rgb)*count + total_count += count + mean_brightness = mean_brightness/total_count + + # Separate colors into two lists + lists = ([], []) + for color in counts.keys(): + if brightness(color) <= mean_brightness: + lists[0].append(color) + else: + lists[1].append(color) + print(lists) + + # Find least-bad approximations for each color + def choose_color(cs: list[RGB]) -> RGB: + best = None + best_cost = math.inf + for rgb in palette: + cost = sum(distance_sq(rgb, c) * counts[c] for c in cs) + if cost < best_cost: + best = rgb + best_cost = cost + return best + dark, light = tuple(choose_color(l) for l in lists) + if dark == light: + light = None + print(dark, light) + return dark, light + + +def two_tone(img: Image, xy1: tuple[int,int] = None, xy2: tuple[int,int] = None) -> None: + # choose the two colors + x1, y1 = xy1 or (0, 0) + x2, y2 = xy2 or (img.width, img.height) + x1 = max(x1, 0) + y1 = max(y1, 0) + x2 = min(x2, img.width) + y2 = min(y2, img.height) + counts = {} + for x in range(x1, x2): + for y in range(y1, y2): + color = palette[img.getpixel((x,y))] + if color not in counts: + counts[color] = 0 + counts[color] += 1 + dark, light = choose_color_pair_luminance(counts) + if light == None: + # Only one color, *ought* to be a no-op + light = dark + colors = [dark, light] + + # change each pixel's color to nearest + for x in range(x1, x2): + for y in range(y1, y2): + color = palette[img.getpixel((x, y))] + replacement = nearest_color(color, colors) + img.putpixel((x, y), palette_indexes[replacement]) + + +BLOCK_SIZE = 10 +blocked = img.copy() +for x in range(0, img.width, BLOCK_SIZE): + for y in range(0, img.height, BLOCK_SIZE): + two_tone(blocked, (x, y), (x + BLOCK_SIZE, y + BLOCK_SIZE))