#!/usr/bin/env python3 from typing import BinaryIO, TextIO from PIL import Image from argparse import ArgumentParser, RawTextHelpFormatter from textwrap import dedent class Converter: CC_COLORS = [ ("0", "colors.white"), ("1", "colors.orange"), ("2", "colors.magenta"), ("3", "colors.lightBlue"), ("4", "colors.yellow"), ("5", "colors.lime"), ("6", "colors.pink"), ("7", "colors.gray"), ("8", "colors.lightGray"), ("9", "colors.cyan"), ("a", "colors.purple"), ("b", "colors.blue"), ("c", "colors.brown"), ("d", "colors.green"), ("e", "colors.red"), ("f", "colors.black"), ] PIX_BITS = [[1, 2], [4, 8], [16, 0]] MAX_DIFF = (3**0.5) * 255 def __init__(self, image: Image.Image): self._img = image.convert("P", palette=Image.ADAPTIVE, colors=16) self._palette: list[int] = self._img.getpalette() or [] if len(self._palette) < 16 * 3: self._palette += [0] * ((16 * 3) - len(self._palette)) def _brightness(self, i: int) -> float: r, g, b = self._palette[i * 3 : (i + 1) * 3] return (r + g + b) / 768 def _distance(self, a: int, b: int) -> float: r1, g1, b1 = self._palette[a * 3 : (a + 1) * 3] r2, g2, b2 = self._palette[b * 3 : (b + 1) * 3] return ( (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 ) ** 0.5 / self.MAX_DIFF def _get_colors(self, x: int, y: int) -> tuple[int, int]: brightest_i, brightest_l = 0, 0 darkest_i, darkest_l = 0, 768 for oy, line in enumerate(self.PIX_BITS): for ox, bit in enumerate(line): pix = self._img.getpixel((x + ox, y + oy)) brightness = self._brightness(pix) if brightness > brightest_l: brightest_l, brightest_i = brightness, pix if brightness < darkest_l: darkest_l, darkest_i = brightness, pix return darkest_i, brightest_i def _is_darker(self, bg: int, fg: int, c: int) -> bool: return self._distance(bg, c) < self._distance(fg, c) def _get_block(self, x: int, y: int) -> tuple[int, int, int]: dark_i, bri_i = self._get_colors(x, y) out: int = 0 for oy, line in enumerate(self.PIX_BITS): for ox, bit in enumerate(line): if not self._is_darker( dark_i, bri_i, self._img.getpixel((x + ox, y + oy)) ): out |= bit # bottom right pixel fix? if not self._is_darker(dark_i, bri_i, self._img.getpixel((x + 1, y + 2))): out ^= 31 dark_i, bri_i = bri_i, dark_i return out, dark_i, bri_i @staticmethod def _write_varint(fp: BinaryIO, value: int): value &= 0xFFFFFFFF mask: int = 0xFFFFFF80 while True: if (value & mask) == 0: fp.write(bytes([value & 0xFF])) return fp.write(bytes([(value & 0x7F) | 0x80])) value >>= 7 def export_binary(self, io: BinaryIO): if self._img.width <= 510 and self._img.height <= 765: io.write(b"CCPI") # old format io.write(bytes([self._img.width // 2, self._img.height // 3, 0])) io.write(bytes(self._palette[: 16 * 3])) else: io.write(b"CPI\x01") # CPIv1 self._write_varint(io, self._img.width // 2) self._write_varint(io, self._img.height // 3) io.write(bytes(self._palette[: 16 * 3])) written = 0 for y in range(0, self._img.height - 2, 3): line: bytearray = bytearray() for x in range(0, self._img.width - 1, 2): ch, bg, fg = self._get_block(x, y) line.extend([(ch + 0x80) & 0xFF, fg << 4 | bg]) written += io.write(line) assert written == (self._img.width // 2) * (self._img.height // 3) * 2 def export(self, io: TextIO): io.write("local m = peripheral.find('monitor')\n") io.write("m.setTextScale(0.5)\n") io.write(f"-- image: {self._img.width}x{self._img.height}\n") io.write("\n") io.write("-- configuring palette\n") for i in range(16): r, g, b = self._palette[i * 3 : (i + 1) * 3] io.write( f"m.setPaletteColor({self.CC_COLORS[i][1]}, 0x{r:02x}{g:02x}{b:02x})\n" ) io.write("\n") io.write("-- writing pixels\n") for i, y in enumerate(range(0, self._img.height - 2, 3), 1): s = [] bgs = "" fgs = "" io.write(f"m.setCursorPos(1, {i}); ") for x in range(0, self._img.width - 1, 2): ch, bg, fg = self._get_block(x, y) s.append(ch + 0x80) bgs += self.CC_COLORS[bg][0] fgs += self.CC_COLORS[fg][0] io.write( "m.blit(string.char(%s), '%s', '%s')\n" % (str.join(", ", map(str, s)), fgs, bgs) ) def main(): parser = ArgumentParser( description="ComputerCraft Palette Image converter", formatter_class=RawTextHelpFormatter, ) parser.add_argument( "-t", dest="textmode", action="store_true", help="Output a Lua script instead of binary image", ) parser.add_argument( "-W", dest="width", default=4 * 8 - 1, type=int, help="Width in characters", ) parser.add_argument( "-H", dest="height", default=3 * 6 - 2, type=int, help="Height in characters", ) parser.add_argument( "-p", dest="placement", choices=("center", "cover", "tile", "full", "extend", "fill"), default="full", help=dedent( """\ Image placement mode (same as in hsetroot) -p center Render image centered on screen -p cover Centered on screen, scaled to fill fully -p tile Render image tiles -p full Maximum aspect ratio -p extend Same as "full" but filling borders -p fill Stretch to fill""" ), ) parser.add_argument("image_path") parser.add_argument("output_path") args = parser.parse_args() with Image.new("RGB", (args.width * 2, args.height * 3)) as canv: with Image.open(args.image_path).convert("RGB") as img: if args.placement == "fill": canv.paste(img.resize(canv.size), (0, 0)) elif args.placement in ("full", "extend", "cover"): aspect = canv.width / img.width if (img.height * aspect > canv.height) != ( args.placement == "cover" ): aspect = canv.height / img.height new_w, new_h = int(img.width * aspect), int( img.height * aspect ) top = int((canv.height - new_h) / 2) left = int((canv.width - new_w) / 2) resized_img = img.resize((new_w, new_h)) canv.paste(resized_img, (left, top)) if args.placement == "extend": if left > 0: right = left - 1 + new_w w = 1 while right + w < canv.width: canv.paste( canv.crop( (left + 1 - w, 0, left + 1, canv.height) ), (left + 1 - w * 2, 0), ) canv.paste( canv.crop((right, 0, right + w, canv.height)), (right + w, 0), ) w *= 2 if top > 0: bottom = top - 1 + new_h h = 1 while bottom + h < canv.height: canv.paste( canv.crop( (0, top + 1 - h, canv.width, top + 1) ), (top + 1 - h * 2, 0), ) canv.paste( canv.crop((0, bottom, canv.width, bottom + h)), (0, bottom + h), ) h *= 2 elif args.placement in ("center", "tile"): left = int((canv.width - img.width) / 2) top = int((canv.height - img.height) / 2) if args.placement == "tile": while left > 0: left -= img.width while top > 0: top -= img.height x = left while x < canv.width: y = top while y < canv.height: canv.paste(img, (x, y)) y += img.height x += img.width else: canv.paste(img, (left, top)) else: pass converter = Converter(canv) converter._img.save("/tmp/_ccpictmp.png") if args.textmode: with open(args.output_path, "w") as fp: converter.export(fp) else: with open(args.output_path, "wb") as fp: converter.export_binary(fp) if __name__ == "__main__": main()