From 82e5779a3ac32e296d7177506b92c553e2675301 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 3 Jan 2021 23:23:15 +0000 Subject: [PATCH] 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. --- dither.py | 255 +++++++++++++++++++++++++++++------------------------- 1 file changed, 138 insertions(+), 117 deletions(-) diff --git a/dither.py b/dither.py index b9e8e32..ccb3c67 100644 --- a/dither.py +++ b/dither.py @@ -8,6 +8,7 @@ import colormath.color_diff import colormath.color_objects import numpy as np + # TODO: # - compare to bmp2dhr and a2bestpix # - deal with fringing @@ -15,9 +16,6 @@ import numpy as np # average error # - optimize Dither.apply() critical path -X_RES = 560 -Y_RES = 192 - 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) @@ -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) - 0.055) + def srgb_to_linear(im: Image) -> Image: a = np.array(im, dtype=np.float32) / 255.0 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: 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)) @@ -62,26 +61,6 @@ RGB = { } # 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 = { (False, False, False, False): np.array((0, 0, 0)), # Black (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, True): np.array((244, 247, 244)), # White } -# + # # Virtual II (sRGB) # sRGB = { # (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 def distance(self, rgb1: Tuple[int], rgb2: Tuple[int]) -> float: - delta_rgb = ((rgb1[0] - rgb2[0])/255, (rgb1[1] - rgb2[1])/255, - (rgb1[2] - rgb2[2])/255) + delta_rgb = ((rgb1[0] - rgb2[0]) / 255, (rgb1[1] - rgb2[1]) / 255, + (rgb1[2] - rgb2[2]) / 255) luma_diff = (self._to_luma(rgb1) - self._to_luma(rgb2)) / 255 return ( @@ -182,44 +161,129 @@ class CCIR601Distance(ColourDistance): luma_diff * luma_diff) -def find_closest_color(pixel, last_pixel, x: int): - least_diff = 1e9 - best_colour = None +class Screen: + X_RES = None + Y_RES = None + X_PIXEL_WIDTH = None - 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) - for v in (RGB[last_dots], RGB[other_dots]): - diff = np.sum(np.power(v - np.array(pixel), 2)) - if diff < least_diff: - least_diff = diff - best_colour = v - return best_colour + def __init__(self): + self.main = np.zeros(8192, dtype=np.uint8) + self.aux = np.zeros(8192, dtype=np.uint8) + + @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 _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): - least_diff = 1e9 - best_colour = None +class DHGR140Screen(Screen): + """DHGR screen ignoring colour fringing, i.e. treating as 140x192x16.""" - for v in RGB.values(): - diff = differ.distance(tuple(v), pixel) - if diff < least_diff: - least_diff = diff - best_colour = v - return best_colour + X_RES = 140 + Y_RES = 192 + X_PIXEL_WIDTH = 4 + + def _image_to_bitmap(self, image: Image) -> np.ndarray: + 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: PATTERN = 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( self.PATTERN)): xx = x + offset[1] - self.ORIGIN[1] 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 new_pixel = image.getpixel((xx, yy)) + error_fraction * quant_error image.putpixel((xx, yy), tuple(new_pixel.astype(int))) @@ -269,85 +333,44 @@ def SRGBResize(im, size, filter): return Image.fromarray(arrOut) -def open_image(filename: str) -> Image: +def open_image(screen: Screen, filename: str) -> Image: im = Image.open(filename) + # TODO: convert to sRGB colour profile explicitly, in case it has some other + # profile already. if im.mode != "RGB": im = im.convert("RGB") - # rgb_linear = srgb_to_linear(np.array(im, dtype=np.float32) / 255.0) - # im = Image.fromarray(rgb_linear * 255) - return srgb_to_linear(SRGBResize(im, (X_RES // 4, Y_RES), Image.LANCZOS)) - # return SRGBResize(im, (X_RES // 4, Y_RES), Image.LANCZOS) + return srgb_to_linear( + SRGBResize(im, (screen.X_RES, screen.Y_RES), + Image.LANCZOS)) -def dither_image(image: Image, dither: Dither, differ: ColourDistance) -> Image: - for y in range(Y_RES): +def dither_image( + screen: Screen, image: Image, dither: Dither, differ: ColourDistance +) -> Image: + for y in range(screen.Y_RES): print(y) 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)) - 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)) quant_error = old_pixel - new_pixel - dither.apply(image, x, y, quant_error) + dither.apply(screen, image, x, y, quant_error) 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(): parser = argparse.ArgumentParser() parser.add_argument("input", type=str, help="Input file to process") parser.add_argument("output", type=str, help="Output file for ") - args = parser.parse_args() - image = open_image(args.input) + # screen = DHGR140Screen() + screen = DHGR560Screen() + args = parser.parse_args() + image = open_image(screen, args.input) image.show() # dither = FloydSteinbergDither() @@ -357,16 +380,14 @@ def main(): # differ = CIE2000Distance() differ = CCIR601Distance() - output = dither_image(image, dither, differ) - # output.show() - screen = Screen(output) + output = dither_image(screen, image, dither, differ) linear_to_srgb(output).show() # bitmap = Image.fromarray(screen.bitmap.astype('uint8') * 255) - screen.pack() + screen.pack(output) with open(args.output, "wb") as f: - f.write(screen.main) - f.write(screen.aux) + f.write(bytes(screen.main)) + f.write(bytes(screen.aux)) if __name__ == "__main__":