Allow modeling screen as 140x192x16 pixels (i.e. ignoring fringing) or

560x192 where each pixel has a choice of two colours.

The latter doesn't give good results currently, it produces long runs
of colour, presumably when the immediate next choice for dithering is
worse than the current one.  i.e. it gets easily stuck in a local
minimum.

Looking ahead N pixels and computing the 2^N options should improve
this.
This commit is contained in:
kris 2021-01-03 23:23:15 +00:00
parent ff7a7365bb
commit 82e5779a3a

255
dither.py
View File

@ -8,6 +8,7 @@ import colormath.color_diff
import colormath.color_objects import colormath.color_objects
import numpy as np import numpy as np
# TODO: # TODO:
# - compare to bmp2dhr and a2bestpix # - compare to bmp2dhr and a2bestpix
# - deal with fringing # - deal with fringing
@ -15,9 +16,6 @@ import numpy as np
# average error # average error
# - optimize Dither.apply() critical path # - optimize Dither.apply() critical path
X_RES = 560
Y_RES = 192
def srgb_to_linear_array(a: np.ndarray, gamma=2.4) -> np.ndarray: def srgb_to_linear_array(a: np.ndarray, gamma=2.4) -> np.ndarray:
return np.where(a <= 0.04045, a / 12.92, ((a + 0.055) / 1.055) ** gamma) return np.where(a <= 0.04045, a / 12.92, ((a + 0.055) / 1.055) ** gamma)
@ -27,6 +25,7 @@ def linear_to_srgb_array(a: np.ndarray, gamma=2.4) -> np.ndarray:
return np.where(a <= 0.0031308, a * 12.92, 1.055 * a ** (1.0 / gamma) - return np.where(a <= 0.0031308, a * 12.92, 1.055 * a ** (1.0 / gamma) -
0.055) 0.055)
def srgb_to_linear(im: Image) -> Image: def srgb_to_linear(im: Image) -> Image:
a = np.array(im, dtype=np.float32) / 255.0 a = np.array(im, dtype=np.float32) / 255.0
rgb_linear = srgb_to_linear_array(a, gamma=2.4) rgb_linear = srgb_to_linear_array(a, gamma=2.4)
@ -36,7 +35,7 @@ def srgb_to_linear(im: Image) -> Image:
def linear_to_srgb(im: Image) -> Image: def linear_to_srgb(im: Image) -> Image:
a = np.array(im, dtype=np.float32) / 255.0 a = np.array(im, dtype=np.float32) / 255.0
srgb = linear_to_srgb_array(a, gamma = 2.4) srgb = linear_to_srgb_array(a, gamma=2.4)
return Image.fromarray((np.clip(srgb, 0.0, 1.0) * 255).astype(np.uint8)) return Image.fromarray((np.clip(srgb, 0.0, 1.0) * 255).astype(np.uint8))
@ -62,26 +61,6 @@ RGB = {
} }
# OpenEmulator # OpenEmulator
# RGB = {
# (False, False, False, False): np.array((0, 0, 0)), # Black
# (False, False, False, True): np.array((189, 0, 102)), # Magenta
# (False, False, True, False): np.array((81, 86, 0)), # Brown
# (False, False, True, True): np.array((238, 55, 0)), # Orange
# (False, True, False, False): np.array((3, 135, 0)), # Dark green
# # XXX RGB values are used as keys in DOTS dict, need to be unique
# (False, True, False, True): np.array((111, 111, 111)), # Grey1
# (False, True, True, False): np.array((14, 237, 0)), # Green
# (False, True, True, True): np.array((204, 213, 0)), # Yellow
# (True, False, False, False): np.array((13, 0, 242)), # Dark blue
# (True, False, False, True): np.array((221, 0, 241)), # Violet
# (True, False, True, False): np.array((112, 112, 112)), # Grey2
# (True, False, True, True): np.array((236, 72, 229)), # Pink
# (True, True, False, False): np.array((0, 157, 241)), # Med blue
# (True, True, False, True): np.array((142, 133, 240)), # Light blue
# (True, True, True, False): np.array((39, 247, 117)), # Aqua
# (True, True, True, True): np.array((236, 236, 236)), # White
# }
sRGB = { sRGB = {
(False, False, False, False): np.array((0, 0, 0)), # Black (False, False, False, False): np.array((0, 0, 0)), # Black
(False, False, False, True): np.array((206, 0, 123)), # Magenta (False, False, False, True): np.array((206, 0, 123)), # Magenta
@ -101,7 +80,7 @@ sRGB = {
(True, True, True, False): np.array((21, 241, 132)), # Aqua (True, True, True, False): np.array((21, 241, 132)), # Aqua
(True, True, True, True): np.array((244, 247, 244)), # White (True, True, True, True): np.array((244, 247, 244)), # White
} }
#
# # Virtual II (sRGB) # # Virtual II (sRGB)
# sRGB = { # sRGB = {
# (False, False, False, False): np.array((0, 0, 0)), # Black # (False, False, False, False): np.array((0, 0, 0)), # Black
@ -171,8 +150,8 @@ class CCIR601Distance(ColourDistance):
return rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 return rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114
def distance(self, rgb1: Tuple[int], rgb2: Tuple[int]) -> float: def distance(self, rgb1: Tuple[int], rgb2: Tuple[int]) -> float:
delta_rgb = ((rgb1[0] - rgb2[0])/255, (rgb1[1] - rgb2[1])/255, delta_rgb = ((rgb1[0] - rgb2[0]) / 255, (rgb1[1] - rgb2[1]) / 255,
(rgb1[2] - rgb2[2])/255) (rgb1[2] - rgb2[2]) / 255)
luma_diff = (self._to_luma(rgb1) - self._to_luma(rgb2)) / 255 luma_diff = (self._to_luma(rgb1) - self._to_luma(rgb2)) / 255
return ( return (
@ -182,44 +161,129 @@ class CCIR601Distance(ColourDistance):
luma_diff * luma_diff) luma_diff * luma_diff)
def find_closest_color(pixel, last_pixel, x: int): class Screen:
least_diff = 1e9 X_RES = None
best_colour = None Y_RES = None
X_PIXEL_WIDTH = None
last_dots = DOTS[tuple(last_pixel)] def __init__(self):
other_dots = list(last_dots) self.main = np.zeros(8192, dtype=np.uint8)
other_dots[x % 4] = not other_dots[x % 4] self.aux = np.zeros(8192, dtype=np.uint8)
other_dots = tuple(other_dots)
for v in (RGB[last_dots], RGB[other_dots]): @staticmethod
diff = np.sum(np.power(v - np.array(pixel), 2)) def y_to_base_addr(y: int) -> int:
if diff < least_diff: """Maps y coordinate to screen memory base address."""
least_diff = diff a = y // 64
best_colour = v d = y - 64 * a
return best_colour b = d // 8
c = d - 8 * b
return 1024 * c + 128 * b + 40 * a
def _image_to_bitmap(self, image: Image) -> np.ndarray:
raise NotImplementedError
def pack(self, image: Image):
bitmap = self._image_to_bitmap(image)
# The DHGR display encodes 7 pixels across interleaved 4-byte sequences
# of AUX and MAIN memory, as follows:
# PBBBAAAA PDDCCCCB PFEEEEDD PGGGGFFF
# Aux N Main N Aux N+1 Main N+1 (N even)
main_col = np.zeros(
(self.Y_RES, self.X_RES * self.X_PIXEL_WIDTH // 14), dtype=np.uint8)
aux_col = np.zeros(
(self.Y_RES, self.X_RES * self.X_PIXEL_WIDTH // 14), dtype=np.uint8)
for byte_offset in range(80):
column = np.zeros(self.Y_RES, dtype=np.uint8)
for bit in range(7):
column |= (bitmap[:, 7 * byte_offset + bit].astype(
np.uint8) << bit)
if byte_offset % 2 == 0:
aux_col[:, byte_offset // 2] = column
else:
main_col[:, (byte_offset - 1) // 2] = column
for y in range(self.Y_RES):
addr = self.y_to_base_addr(y)
self.aux[addr:addr + 40] = aux_col[y, :]
self.main[addr:addr + 40] = main_col[y, :]
@staticmethod
def pixel_palette_options(last_pixel, x: int):
raise NotImplementedError
@staticmethod
def find_closest_color(pixel, palette_options, differ: ColourDistance):
least_diff = 1e9
best_colour = None
for v in palette_options:
diff = differ.distance(tuple(v), pixel)
if diff < least_diff:
least_diff = diff
best_colour = v
return best_colour
def find_closest_color(pixel, last_pixel, x: int, differ: ColourDistance): class DHGR140Screen(Screen):
least_diff = 1e9 """DHGR screen ignoring colour fringing, i.e. treating as 140x192x16."""
best_colour = None
for v in RGB.values(): X_RES = 140
diff = differ.distance(tuple(v), pixel) Y_RES = 192
if diff < least_diff: X_PIXEL_WIDTH = 4
least_diff = diff
best_colour = v def _image_to_bitmap(self, image: Image) -> np.ndarray:
return best_colour bitmap = np.zeros(
(self.Y_RES, self.X_RES * self.X_PIXEL_WIDTH), dtype=np.bool)
for y in range(self.Y_RES):
for x in range(self.X_RES):
pixel = image.getpixel((x, y))
dots = DOTS[pixel]
bitmap[y, x * self.X_PIXEL_WIDTH:(
(x + 1) * self.X_PIXEL_WIDTH)] = dots
return bitmap
@staticmethod
def pixel_palette_options(last_pixel, x: int):
return RGB.values()
class DHGR560Screen(Screen):
"""DHGR screen including colour fringing."""
X_RES = 560
Y_RES = 192
X_PIXEL_WIDTH = 1
def _image_to_bitmap(self, image: Image) -> np.ndarray:
bitmap = np.zeros((self.Y_RES, self.X_RES), dtype=np.bool)
for y in range(self.Y_RES):
for x in range(self.X_RES):
pixel = image.getpixel((x, y))
dots = DOTS[pixel]
phase = x % 4
bitmap[y, x] = dots[phase]
return bitmap
def pixel_palette_options(self, last_pixel, x: int):
last_dots = DOTS[tuple(last_pixel)]
other_dots = list(last_dots)
other_dots[x % 4] = not other_dots[x % 4]
other_dots = tuple(other_dots)
return RGB[last_dots], RGB[other_dots]
class Dither: class Dither:
PATTERN = None PATTERN = None
ORIGIN = None ORIGIN = None
def apply(self, image, x, y, quant_error): def apply(self, screen: Screen, image: Image, x: int, y: int,
quant_error: float):
for offset, error_fraction in np.ndenumerate(self.PATTERN / np.sum( for offset, error_fraction in np.ndenumerate(self.PATTERN / np.sum(
self.PATTERN)): self.PATTERN)):
xx = x + offset[1] - self.ORIGIN[1] xx = x + offset[1] - self.ORIGIN[1]
yy = y + offset[0] - self.ORIGIN[0] yy = y + offset[0] - self.ORIGIN[0]
if xx < 0 or yy < 0 or xx > (X_RES // 4 - 1) or yy > (Y_RES - 1): if xx < 0 or yy < 0 or xx > (screen.X_RES - 1) or (
yy > (screen.Y_RES - 1)):
continue continue
new_pixel = image.getpixel((xx, yy)) + error_fraction * quant_error new_pixel = image.getpixel((xx, yy)) + error_fraction * quant_error
image.putpixel((xx, yy), tuple(new_pixel.astype(int))) image.putpixel((xx, yy), tuple(new_pixel.astype(int)))
@ -269,85 +333,44 @@ def SRGBResize(im, size, filter):
return Image.fromarray(arrOut) return Image.fromarray(arrOut)
def open_image(filename: str) -> Image: def open_image(screen: Screen, filename: str) -> Image:
im = Image.open(filename) im = Image.open(filename)
# TODO: convert to sRGB colour profile explicitly, in case it has some other
# profile already.
if im.mode != "RGB": if im.mode != "RGB":
im = im.convert("RGB") im = im.convert("RGB")
# rgb_linear = srgb_to_linear(np.array(im, dtype=np.float32) / 255.0) return srgb_to_linear(
# im = Image.fromarray(rgb_linear * 255) SRGBResize(im, (screen.X_RES, screen.Y_RES),
return srgb_to_linear(SRGBResize(im, (X_RES // 4, Y_RES), Image.LANCZOS)) Image.LANCZOS))
# return SRGBResize(im, (X_RES // 4, Y_RES), Image.LANCZOS)
def dither_image(image: Image, dither: Dither, differ: ColourDistance) -> Image: def dither_image(
for y in range(Y_RES): screen: Screen, image: Image, dither: Dither, differ: ColourDistance
) -> Image:
for y in range(screen.Y_RES):
print(y) print(y)
new_pixel = (0, 0, 0) new_pixel = (0, 0, 0)
for x in range(X_RES // 4): for x in range(screen.X_RES):
old_pixel = image.getpixel((x, y)) old_pixel = image.getpixel((x, y))
new_pixel = find_closest_color(old_pixel, new_pixel, x, differ) palette_choices = screen.pixel_palette_options(new_pixel, x)
new_pixel = screen.find_closest_color(
old_pixel, palette_choices, differ)
image.putpixel((x, y), tuple(new_pixel)) image.putpixel((x, y), tuple(new_pixel))
quant_error = old_pixel - new_pixel quant_error = old_pixel - new_pixel
dither.apply(image, x, y, quant_error) dither.apply(screen, image, x, y, quant_error)
return image return image
class Screen:
def __init__(self, image: Image):
self.bitmap = np.zeros((Y_RES, X_RES), dtype=np.bool)
self.main = np.zeros(8192, dtype=np.uint8)
self.aux = np.zeros(8192, dtype=np.uint8)
for y in range(Y_RES):
for x in range(X_RES // 4):
pixel = image.getpixel((x, y))
dots = DOTS[pixel]
# phase = x % 4
# self.bitmap[y, x] = dots[phase]
self.bitmap[y, x * 4:(x + 1) * 4] = dots
@staticmethod
def y_to_base_addr(y: int) -> int:
"""Maps y coordinate to screen memory base address."""
a = y // 64
d = y - 64 * a
b = d // 8
c = d - 8 * b
return 1024 * c + 128 * b + 40 * a
def pack(self):
# The DHGR display encodes 7 pixels across interleaved 4-byte sequences
# of AUX and MAIN memory, as follows:
# PBBBAAAA PDDCCCCB PFEEEEDD PGGGGFFF
# Aux N Main N Aux N+1 Main N+1 (N even)
main_col = np.zeros((Y_RES, X_RES // 14), dtype=np.uint8)
aux_col = np.zeros((Y_RES, X_RES // 14), dtype=np.uint8)
for byte_offset in range(80):
column = np.zeros(Y_RES, dtype=np.uint8)
for bit in range(7):
column |= (self.bitmap[:, 7 * byte_offset + bit].astype(
np.uint8) << bit)
if byte_offset % 2 == 0:
aux_col[:, byte_offset // 2] = column
else:
main_col[:, (byte_offset - 1) // 2] = column
for y in range(Y_RES):
addr = self.y_to_base_addr(y)
self.aux[addr:addr + 40] = aux_col[y, :]
self.main[addr:addr + 40] = main_col[y, :]
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("input", type=str, help="Input file to process") parser.add_argument("input", type=str, help="Input file to process")
parser.add_argument("output", type=str, help="Output file for ") parser.add_argument("output", type=str, help="Output file for ")
args = parser.parse_args() # screen = DHGR140Screen()
image = open_image(args.input) screen = DHGR560Screen()
args = parser.parse_args()
image = open_image(screen, args.input)
image.show() image.show()
# dither = FloydSteinbergDither() # dither = FloydSteinbergDither()
@ -357,16 +380,14 @@ def main():
# differ = CIE2000Distance() # differ = CIE2000Distance()
differ = CCIR601Distance() differ = CCIR601Distance()
output = dither_image(image, dither, differ) output = dither_image(screen, image, dither, differ)
# output.show()
screen = Screen(output)
linear_to_srgb(output).show() linear_to_srgb(output).show()
# bitmap = Image.fromarray(screen.bitmap.astype('uint8') * 255) # bitmap = Image.fromarray(screen.bitmap.astype('uint8') * 255)
screen.pack() screen.pack(output)
with open(args.output, "wb") as f: with open(args.output, "wb") as f:
f.write(screen.main) f.write(bytes(screen.main))
f.write(screen.aux) f.write(bytes(screen.aux))
if __name__ == "__main__": if __name__ == "__main__":