cc-stuff/cc-pic.py

296 lines
10 KiB
Python
Raw Normal View History

2023-10-15 03:13:42 +03:00
#!/usr/bin/env python3
from typing import BinaryIO, TextIO
from PIL import Image
2024-01-18 15:08:13 +03:00
from argparse import ArgumentParser, RawTextHelpFormatter
from textwrap import dedent
from functools import lru_cache
2024-01-18 15:08:13 +03:00
2023-10-15 03:13:42 +03:00
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"),
]
2024-01-18 15:08:13 +03:00
PIX_BITS = [[1, 2], [4, 8], [16, 0]]
2023-10-15 03:13:42 +03:00
MAX_DIFF = 3 * 255
2023-10-15 03:13:42 +03:00
2023-10-17 14:40:09 +03:00
def __init__(self, image: Image.Image):
self._img = image.convert("P", palette=Image.ADAPTIVE, colors=16)
self._imgdata = self._img.load()
2024-01-18 15:08:13 +03:00
self._palette: list[int] = self._img.getpalette() or []
if len(self._palette) < 16 * 3:
self._palette += [0] * ((16 * 3) - len(self._palette))
2023-10-15 03:13:42 +03:00
@lru_cache
2023-10-15 03:13:42 +03:00
def _brightness(self, i: int) -> float:
r, g, b = self._palette[i * 3 : (i + 1) * 3]
return (r + g + b) / 768
@lru_cache
2023-10-15 03:13:42 +03:00
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]
rd, gd, bd = r1 - r2, g1 - g2, b1 - b2
return (rd * rd + gd * gd + bd * bd) / self.MAX_DIFF
2023-10-15 03:13:42 +03:00
@lru_cache
2023-10-15 03:13:42 +03:00
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._imgdata[x + ox, y + oy]
2023-10-15 03:13:42 +03:00
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):
2024-01-18 15:08:13 +03:00
if not self._is_darker(
dark_i, bri_i, self._imgdata[x + ox, y + oy]
2024-01-18 15:08:13 +03:00
):
2023-10-15 03:13:42 +03:00
out |= bit
# bottom right pixel fix?
if not self._is_darker(dark_i, bri_i, self._imgdata[x + 1, y + 2]):
out ^= 31
dark_i, bri_i = bri_i, dark_i
2023-10-15 03:13:42 +03:00
return out, dark_i, bri_i
2024-01-18 16:26:57 +03:00
@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, version: int = -1):
if version == -1:
if self._img.width <= 255 * 2 and self._img.height < 255 * 3:
version = 0
else:
version = 1
if version == 0:
io.write(b"CCPI") # old format
2024-01-18 16:26:57 +03:00
io.write(bytes([self._img.width // 2, self._img.height // 3, 0]))
io.write(bytes(self._palette[: 16 * 3]))
elif version == 1:
io.write(b"CPI\x01") # CPIv1
2024-01-18 16:26:57 +03:00
self._write_varint(io, self._img.width // 2)
self._write_varint(io, self._img.height // 3)
io.write(bytes(self._palette[: 16 * 3]))
else:
raise ValueError(f"invalid version {version}")
2023-10-15 03:13:42 +03:00
for y in range(0, self._img.height - 2, 3):
2024-01-18 16:26:57 +03:00
line: bytearray = bytearray()
2023-10-15 03:13:42 +03:00
for x in range(0, self._img.width - 1, 2):
ch, bg, fg = self._get_block(x, y)
2024-01-18 16:26:57 +03:00
line.extend([(ch + 0x80) & 0xFF, fg << 4 | bg])
io.write(line)
2023-10-15 03:13:42 +03:00
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]
2024-01-18 15:08:13 +03:00
io.write(
f"m.setPaletteColor({self.CC_COLORS[i][1]}, 0x{r:02x}{g:02x}{b:02x})\n"
)
2023-10-15 03:13:42 +03:00
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]
2024-01-18 15:08:13 +03:00
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(
"-V",
dest="cpi_version",
type=int,
default=-1,
choices=(-1, 0, 1),
help=dedent(
"""\
Force specific CPI version to be used.
Only applies to binary format.
Valid versions:
-V -1 Choose any fitting one
For images smaller than 255x255, uses CPIv0
-V 0 OG CPI, 255x255 maximum, uncompressed
-V 1 CPIv1, huge images, uncompressed"""
),
)
2024-01-18 15:08:13 +03:00
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, args.cpi_version)
2023-10-15 03:13:42 +03:00
2024-01-18 15:08:13 +03:00
if __name__ == "__main__":
main()