From 467a0cd196d499fab3d35292085ba64b5e5b59e2 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 15 Mar 2021 10:45:33 +0000 Subject: [PATCH] Tidy up a bit to prepare for merge --- convert.py | 73 +++++++++++++++-------------- ntsc_colours.py | 2 +- palette.py | 4 ++ screen.py | 120 +++++++++++++++++++++++++----------------------- 4 files changed, 103 insertions(+), 96 deletions(-) diff --git a/convert.py b/convert.py index 4c0b986..867524d 100644 --- a/convert.py +++ b/convert.py @@ -27,83 +27,82 @@ def main(): parser.add_argument( "--lookahead", type=int, default=6, help=("How many pixels to look ahead to compensate for NTSC colour " - "artifacts. Default: 6")) + "artifacts (default: 6)")) parser.add_argument( '--dither', type=str, choices=list(dither_pattern.PATTERNS.keys()), default=dither_pattern.DEFAULT_PATTERN, - help="Error distribution pattern to apply when dithering. Default: " - + dither_pattern.DEFAULT_PATTERN) + help="Error distribution pattern to apply when dithering (default: " + + dither_pattern.DEFAULT_PATTERN + ")") parser.add_argument( '--show_input', action=argparse.BooleanOptionalAction, default=False, - help="Whether to show the input image before conversion. Default: " - "False") + help="Whether to show the input image before conversion.") parser.add_argument( '--show_output', action=argparse.BooleanOptionalAction, default=True, - help="Default: True. Whether to show the output image after " - "conversion. Default: True") + help="Whether to show the output image after conversion.") parser.add_argument( - '--resolution', type=str, choices=("140", "560", "ntsc"), default="560", + '--resolution', type=str, choices=("140", "560"), default="560", help=("Effective double hi-res resolution to target. '140' treats " "pixels in groups of 4, with 16 colours that are chosen " "independently, and ignores NTSC fringing. This is mostly only " "useful for comparison to other 140px converters. '560' treats " "each pixel individually, with choice of 2 colours (depending on " "NTSC colour phase), and looking ahead over next --lookahead " - "pixels to optimize the colour sequence. 'ntsc' additionally " - "simulates the reduced bandwidth of the NTSC chroma signal, and " - "causes colours to bleed over 8 successive pixels instead of 4. " - "Default: 560") + "pixels to optimize the colour sequence (default: 560)") ) parser.add_argument( - '--palette', type=str, choices=list( - set(palette_py.PALETTES.keys()) - {"ntsc"}), + '--palette', type=str, choices=list(set(palette_py.PALETTES.keys())), default=palette_py.DEFAULT_PALETTE, - help="RGB colour palette to dither to. Ignored for " - "--resolution=ntsc. Default: " + palette_py.DEFAULT_PALETTE) + help='RGB colour palette to dither to. "ntsc" blends colours over 8 ' + 'pixels and gives better image quality on targets that ' + 'use/emulate NTSC, but can be substantially slower. Other ' + 'palettes determine colours based on 4 pixel sequences ' + '(default: ' + palette_py.DEFAULT_PALETTE + ")") parser.add_argument( '--show_palette', type=str, choices=list(palette_py.PALETTES.keys()), - help="RGB colour palette to use when --show_output. Default: " - "value of --palette.") + help="RGB colour palette to use when --show_output (default: " + "value of --palette)") args = parser.parse_args() - if args.resolution == "ntsc": - palette = palette_py.PALETTES["ntsc"]() - screen = screen_py.DHGR560NTSCScreen(palette) - lookahead = args.lookahead + palette = palette_py.PALETTES[args.palette]() + if args.resolution == "140": + if args.palette == "ntsc": + raise argparse.ArgumentError( + "--resolution=140 cannot be combined with --palette=ntsc") + screen = screen_py.DHGR140Screen(palette) + lookahead = 0 else: - palette = palette_py.PALETTES[args.palette]() - if args.resolution == "560": - screen = screen_py.DHGR560Screen(palette) - lookahead = args.lookahead + if args.palette == "ntsc": + # TODO: palette depth should be controlled by Palette not Screen + screen = screen_py.DHGR560NTSCScreen(palette) else: - screen = screen_py.DHGR140Screen(palette) - lookahead = 0 + screen = screen_py.DHGR560Screen(palette) + lookahead = args.lookahead # Open and resize source image image = image_py.open(args.input) if args.show_input: - image_py.resize(image, 560, 384, srgb_output=True).show() + image_py.resize(image, screen.NATIVE_X_RES, screen.NATIVE_Y_RES * 2, + srgb_output=True).show() resized = np.array(image_py.resize(image, screen.X_RES, screen.Y_RES)).astype(np.float32) dither = dither_pattern.PATTERNS[args.dither]() - output_4bit, _ = dither_pyx.dither_image( + output_nbit, _ = dither_pyx.dither_image( screen, resized, dither, lookahead) - bitmap = screen.pack(output_4bit) + bitmap = screen.pack(output_nbit) # Show output image by rendering in target palette - if args.show_palette: - output_palette = palette_py.PALETTES[args.show_palette]() - else: - output_palette = palette - if args.show_palette == 'ntsc': + output_palette_name = args.show_palette or args.palette + output_palette = palette_py.PALETTES[output_palette_name]() + if output_palette_name == "ntsc": output_screen = screen_py.DHGR560NTSCScreen(output_palette) else: output_screen = screen_py.DHGR560Screen(output_palette) output_rgb = output_screen.bitmap_to_image_rgb(bitmap) out_image = Image.fromarray(image_py.linear_to_srgb(output_rgb).astype( np.uint8)) - out_image = image_py.resize(out_image, 560, 384, srgb_output=True) + out_image = image_py.resize(out_image, screen.NATIVE_X_RES, + screen.NATIVE_Y_RES * 2, srgb_output=True) if args.show_output: out_image.show() diff --git a/ntsc_colours.py b/ntsc_colours.py index 1602e38..09062b8 100644 --- a/ntsc_colours.py +++ b/ntsc_colours.py @@ -7,7 +7,7 @@ import screen def main(): - s = screen.DHGR560Screen(palette=None) + s = screen.DHGR560NTSCScreen(palette=None) bitmap = np.zeros((1, 8), dtype=np.bool) colours = {} diff --git a/palette.py b/palette.py index d9a94cb..6319496 100644 --- a/palette.py +++ b/palette.py @@ -41,6 +41,7 @@ class Palette: class ToHgrPalette(Palette): + """4-bit palette used as default by other DHGR image converters.""" DISTANCES_PATH = "data/distances_tohgr.data" PALETTE_DEPTH = 4 @@ -66,6 +67,7 @@ class ToHgrPalette(Palette): class OpenEmulatorPalette(Palette): + """4-bit palette chosen to approximately match OpenEmulator output.""" DISTANCES_PATH = "data/distances_openemulator.data" PALETTE_DEPTH = 4 @@ -91,6 +93,7 @@ class OpenEmulatorPalette(Palette): class VirtualIIPalette(Palette): + """4-bit palette exactly matching Virtual II emulator output.""" DISTANCES_PATH = "data/distances_virtualii.data" PALETTE_DEPTH = 4 @@ -115,6 +118,7 @@ class VirtualIIPalette(Palette): class NTSCPalette(Palette): + """8-bit NTSC palette computed by averaging chroma signal over 8 pixels.""" DISTANCES_PATH = 'data/distances_ntsc.data' PALETTE_DEPTH = 8 diff --git a/screen.py b/screen.py index 0e76e19..c9fcd35 100644 --- a/screen.py +++ b/screen.py @@ -12,6 +12,9 @@ class Screen: Y_RES = None X_PIXEL_WIDTH = None + NATIVE_X_RES = 560 + NATIVE_Y_RES = 192 + def __init__(self, palette: palette_py.Palette): self.main = np.zeros(8192, dtype=np.uint8) self.aux = np.zeros(8192, dtype=np.uint8) @@ -70,10 +73,11 @@ class Screen: window indexed by x % 4, which gives the index into our 16-colour RGB palette. """ - image_rgb = np.empty((192, 560, 3), dtype=np.uint8) + image_rgb = np.empty((self.NATIVE_Y_RES, self.NATIVE_X_RES, 3), + dtype=np.uint8) for y in range(self.Y_RES): pixel = [False, False, False, False] - for x in range(560): + for x in range(self.NATIVE_X_RES): pixel[x % 4] = bitmap[y, x] dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] image_rgb[y, x, :] = self.palette.RGB[dots] @@ -99,60 +103,6 @@ class Screen: return 1 if line[pos] else 0 - def bitmap_to_ntsc(self, bitmap: np.ndarray) -> np.ndarray: - y_width = 12 - u_width = 24 - v_width = 24 - - contrast = 1 - # TODO: where does this come from? OpenEmulator looks like it should - # use a value of 1.0 by default. - saturation = 2 - # Fudge factor to make colours line up with OpenEmulator - # TODO: where does this come from - is it due to the band-pass - # filtering they do? - hue = -0.3 - - # Apply effect of saturation - yuv_to_rgb = np.array( - ((1, 0, 0), (0, saturation, 0), (0, 0, saturation)), dtype=np.float) - # Apply hue phase rotation - yuv_to_rgb = np.matmul(np.array( - ((1, 0, 0), (0, np.cos(hue), np.sin(hue)), (0, -np.sin(hue), - np.cos(hue)))), - yuv_to_rgb) - # Y'UV to R'G'B' conversion - yuv_to_rgb = np.matmul(np.array( - ((1, 0, 1.13983), (1, -0.39465, -.58060), (1, 2.03211, 0))), - yuv_to_rgb) - # Apply effect of contrast - yuv_to_rgb *= contrast - - out_rgb = np.empty((bitmap.shape[0], bitmap.shape[1] * 3, 3), - dtype=np.uint8) - for y in range(bitmap.shape[0]): - ysum = 0 - usum = 0 - vsum = 0 - line = np.repeat(bitmap[y], 3) - - for x in range(bitmap.shape[1] * 3): - ysum += self._read(line, x) - self._read(line, x - y_width) - usum += self._read(line, x) * self._sin(x) - self._read( - line, x - u_width) * self._sin((x - u_width)) - vsum += self._read(line, x) * self._cos(x) - self._read( - line, x - v_width) * self._cos((x - v_width)) - rgb = np.matmul( - yuv_to_rgb, np.array( - (ysum / y_width, usum / u_width, - vsum / v_width)).reshape((3, 1))).reshape(3) - r = min(255, max(0, rgb[0] * 255)) - g = min(255, max(0, rgb[1] * 255)) - b = min(255, max(0, rgb[2] * 255)) - out_rgb[y, x, :] = (r, g, b) - - return out_rgb - class DHGR140Screen(Screen): """DHGR screen ignoring colour fringing, i.e. treating as 140x192x16.""" @@ -233,10 +183,10 @@ class DHGR560NTSCScreen(Screen): window indexed by x % 4, which gives the index into our 256-colour RGB palette. """ - image_rgb = np.empty((192, 560, 3), dtype=np.uint8) + image_rgb = np.empty((self.NATIVE_Y_RES, self.NATIVE_X_RES, 3), dtype=np.uint8) for y in range(self.Y_RES): pixel = [False, False, False, False, False, False, False, False] - for x in range(560): + for x in range(self.NATIVE_X_RES): pixel[x % 4] = pixel[x % 4 + 4] pixel[x % 4 + 4] = bitmap[y, x] dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] @@ -260,3 +210,57 @@ class DHGR560NTSCScreen(Screen): np.array([pixel_4bit_0, pixel_4bit_1], dtype=np.uint8), np.array([self.palette.RGB[pixel_4bit_0], self.palette.RGB[pixel_4bit_1]], dtype=np.uint8)) + + def bitmap_to_ntsc(self, bitmap: np.ndarray) -> np.ndarray: + y_width = 12 + u_width = 24 + v_width = 24 + + contrast = 1 + # TODO: where does this come from? OpenEmulator looks like it should + # use a value of 1.0 by default. + saturation = 2 + # Fudge factor to make colours line up with OpenEmulator + # TODO: where does this come from - is it due to the band-pass + # filtering they do? + hue = -0.3 + + # Apply effect of saturation + yuv_to_rgb = np.array( + ((1, 0, 0), (0, saturation, 0), (0, 0, saturation)), dtype=np.float) + # Apply hue phase rotation + yuv_to_rgb = np.matmul(np.array( + ((1, 0, 0), (0, np.cos(hue), np.sin(hue)), (0, -np.sin(hue), + np.cos(hue)))), + yuv_to_rgb) + # Y'UV to R'G'B' conversion + yuv_to_rgb = np.matmul(np.array( + ((1, 0, 1.13983), (1, -0.39465, -.58060), (1, 2.03211, 0))), + yuv_to_rgb) + # Apply effect of contrast + yuv_to_rgb *= contrast + + out_rgb = np.empty((bitmap.shape[0], bitmap.shape[1] * 3, 3), + dtype=np.uint8) + for y in range(bitmap.shape[0]): + ysum = 0 + usum = 0 + vsum = 0 + line = np.repeat(bitmap[y], 3) + + for x in range(bitmap.shape[1] * 3): + ysum += self._read(line, x) - self._read(line, x - y_width) + usum += self._read(line, x) * self._sin(x) - self._read( + line, x - u_width) * self._sin((x - u_width)) + vsum += self._read(line, x) * self._cos(x) - self._read( + line, x - v_width) * self._cos((x - v_width)) + rgb = np.matmul( + yuv_to_rgb, np.array( + (ysum / y_width, usum / u_width, + vsum / v_width)).reshape((3, 1))).reshape(3) + r = min(255, max(0, rgb[0] * 255)) + g = min(255, max(0, rgb[1] * 255)) + b = min(255, max(0, rgb[2] * 255)) + out_rgb[y, x, :] = (r, g, b) + + return out_rgb