406 lines
14 KiB
Python
Executable File
406 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
from typing import BinaryIO, TextIO
|
|
from PIL import Image, ImageColor
|
|
from argparse import ArgumentParser, RawTextHelpFormatter
|
|
from textwrap import dedent
|
|
from functools import lru_cache
|
|
|
|
try:
|
|
PALETTE_ADAPTIVE = Image.Palette.ADAPTIVE
|
|
except Exception:
|
|
PALETTE_ADAPTIVE = Image.ADAPTIVE
|
|
|
|
|
|
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"),
|
|
]
|
|
|
|
DEFAULT_PALETTE = [
|
|
240, 240, 240,
|
|
242, 178, 51,
|
|
229, 127, 216,
|
|
153, 178, 242,
|
|
222, 222, 108,
|
|
127, 204, 25,
|
|
242, 178, 204,
|
|
76, 76, 76,
|
|
153, 153, 153,
|
|
76, 153, 178,
|
|
178, 102, 229,
|
|
51, 102, 204,
|
|
127, 102, 76,
|
|
87, 166, 78,
|
|
204, 76, 76,
|
|
17, 17, 17
|
|
]
|
|
|
|
DEFAULT_GRAYSCALE_PALETTE = [
|
|
0xf0, 0xf0, 0xf0,
|
|
0x9d, 0x9d, 0x9d,
|
|
0xbe, 0xbe, 0xbe,
|
|
0xbf, 0xbf, 0xbf,
|
|
0xb8, 0xb8, 0xb8,
|
|
0x76, 0x76, 0x76,
|
|
0xd0, 0xd0, 0xd0,
|
|
0x4c, 0x4c, 0x4c,
|
|
0x99, 0x99, 0x99,
|
|
0x87, 0x87, 0x87,
|
|
0xa9, 0xa9, 0xa9,
|
|
0x77, 0x77, 0x77,
|
|
0x65, 0x65, 0x65,
|
|
0x6e, 0x6e, 0x6e,
|
|
0x76, 0x76, 0x76,
|
|
0x11, 0x11, 0x11
|
|
]
|
|
|
|
PIX_BITS = [[1, 2], [4, 8], [16, 0]]
|
|
|
|
MAX_DIFF = 3 * 255
|
|
|
|
def __init__(self, image: Image.Image, palette: list[int] | int = PALETTE_ADAPTIVE):
|
|
if isinstance(palette, list):
|
|
img_pal = Image.new("P", (1, 1))
|
|
img_pal.putpalette(palette)
|
|
self._img = image.quantize(len(palette) // 3, palette=img_pal)
|
|
else:
|
|
self._img = image.convert("P", palette=palette, colors=16)
|
|
|
|
self._imgdata = self._img.load()
|
|
self._palette: list[int] = self._img.getpalette() or []
|
|
if len(self._palette) < 16 * 3:
|
|
self._palette += [0] * ((16 * 3) - len(self._palette))
|
|
|
|
@lru_cache
|
|
def _brightness(self, i: int) -> float:
|
|
r, g, b = self._palette[i * 3 : (i + 1) * 3]
|
|
return (r + g + b) / 768
|
|
|
|
@lru_cache
|
|
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
|
|
|
|
@lru_cache
|
|
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 in range(len(line)):
|
|
pix = self._imgdata[x + ox, y + oy]
|
|
assert pix < 16, f"{pix} is too big at {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
|
|
|
|
@lru_cache()
|
|
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)
|
|
assert dark_i < 16, f"{dark_i} is too big"
|
|
assert bri_i < 16, f"{bri_i} is too big"
|
|
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._imgdata[x + ox, y + oy]
|
|
):
|
|
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
|
|
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, version: int = -1):
|
|
if version == -2:
|
|
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])
|
|
io.write(line)
|
|
return
|
|
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
|
|
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
|
|
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}")
|
|
|
|
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])
|
|
io.write(line)
|
|
|
|
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(
|
|
"-V",
|
|
dest="cpi_version",
|
|
type=int,
|
|
default=-1,
|
|
choices=(-2, -1, 0, 1),
|
|
help=dedent(
|
|
"""\
|
|
Force specific CPI version to be used.
|
|
Only applies to binary format.
|
|
Valid versions:
|
|
-V -2 Uses raw format. No headers, default palette.
|
|
Used for OBCB-CC project.
|
|
-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"""
|
|
),
|
|
)
|
|
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(
|
|
"-P",
|
|
dest="palette",
|
|
default="auto",
|
|
help=dedent(
|
|
"""\
|
|
Palette to be used for that conversion.
|
|
Should be 16 colors or less
|
|
Valid options are:
|
|
-P auto Determine palette automatically
|
|
-P default Use default CC:Tweaked color palette
|
|
-P defaultgray Use default CC:Tweaked grayscale palette
|
|
-P "list:#RRGGBB,#RRGGBB,..." Use a set list of colors
|
|
-P "cpi:path" Load palette from a CCPI file
|
|
-P "gpl:path" Parse GIMP palette file and use first 16 colors
|
|
-P "txt:path" Load palette from a list of hex values
|
|
"""
|
|
)
|
|
)
|
|
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
|
|
|
|
palette = PALETTE_ADAPTIVE
|
|
if args.cpi_version == -2:
|
|
args.palette = "default"
|
|
|
|
if args.palette == "auto":
|
|
palette = PALETTE_ADAPTIVE
|
|
elif args.palette == "default":
|
|
palette = Converter.DEFAULT_PALETTE
|
|
elif args.palette == "defaultgray":
|
|
palette = Converter.DEFAULT_GRAYSCALE_PALETTE
|
|
elif args.palette.startswith("txt:"):
|
|
with open(args.palette[4:], "r") as fp:
|
|
palette = []
|
|
for line in fp:
|
|
palette += ImageColor.getcolor(line.strip(), "RGB") # type: ignore
|
|
assert len(palette) <= 16 * 3
|
|
elif args.palette.startswith("list:"):
|
|
palette = []
|
|
for c in args.palette[5:].split(","):
|
|
palette += ImageColor.getcolor(c, "RGB") # type: ignore
|
|
assert len(palette) <= 16 * 3
|
|
elif args.palette.startswith("cpi:"):
|
|
raise ValueError("not implemented yet")
|
|
elif args.palette.startswith("gpl:"):
|
|
raise ValueError("not implemented yet")
|
|
else:
|
|
raise ValueError(f"invalid palette identifier: {args.palette!r}")
|
|
|
|
converter = Converter(canv, palette)
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|