diff --git a/convert.py b/convert.py index 5a338a2..4c0b986 100644 --- a/convert.py +++ b/convert.py @@ -27,39 +27,57 @@ def main(): parser.add_argument( "--lookahead", type=int, default=6, help=("How many pixels to look ahead to compensate for NTSC colour " - "artifacts.")) + "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.") + 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.") + help="Whether to show the input image before conversion. Default: " + "False") parser.add_argument( '--show_output', action=argparse.BooleanOptionalAction, default=True, - help="Whether to show the output image after conversion.") + help="Default: True. Whether to show the output image after " + "conversion. Default: True") parser.add_argument( - '--resolution', type=int, choices=(560, 140), default=560, - help=("Double hi-res resolution to target. 140 treats pixels in " - "groups of 4, with 16 colours that can be chosen independently, " - "and ignores NTSC fringing. 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.") + '--resolution', type=str, choices=("140", "560", "ntsc"), 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") ) parser.add_argument( - '--palette', type=str, choices=list(palette_py.PALETTES.keys()), + '--palette', type=str, choices=list( + set(palette_py.PALETTES.keys()) - {"ntsc"}), default=palette_py.DEFAULT_PALETTE, - help="RGB colour palette to dither to.") + help="RGB colour palette to dither to. Ignored for " + "--resolution=ntsc. 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.") args = parser.parse_args() - palette = palette_py.PALETTES[args.palette]() - if args.resolution == 560: - screen = screen_py.DHGR560Screen(palette) + if args.resolution == "ntsc": + palette = palette_py.PALETTES["ntsc"]() + screen = screen_py.DHGR560NTSCScreen(palette) lookahead = args.lookahead else: - screen = screen_py.DHGR140Screen(palette) - lookahead = 0 + palette = palette_py.PALETTES[args.palette]() + if args.resolution == "560": + screen = screen_py.DHGR560Screen(palette) + lookahead = args.lookahead + else: + screen = screen_py.DHGR140Screen(palette) + lookahead = 0 # Open and resize source image image = image_py.open(args.input) @@ -69,39 +87,33 @@ def main(): screen.Y_RES)).astype(np.float32) dither = dither_pattern.PATTERNS[args.dither]() - - # start = time.time() - output_4bit, output_rgb = dither_pyx.dither_image( + output_4bit, _ = dither_pyx.dither_image( screen, resized, dither, lookahead) - # print(time.time() - start) - - if args.resolution == 140: - # Show un-fringed 140px output image - out_image = Image.fromarray(image_py.linear_to_srgb(output_rgb).astype( - np.uint8)) - if args.show_output: - image_py.resize(out_image, 560, 384, srgb_output=True).show() - bitmap = screen.pack(output_4bit) - output_rgb = screen.bitmap_to_image_rgb(bitmap) - # Show output image + # 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_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) + if args.show_output: out_image.show() - ntsc = Image.fromarray(screen.bitmap_to_ntsc(bitmap)) - ntsc = image_py.resize(ntsc, 560 * 3 // 2, 384 * 3 // 2, srgb_output=True) - ntsc.show() - # Save Double hi-res image outfile = os.path.join(os.path.splitext(args.output)[0] + "-preview.png") out_image.save(outfile, "PNG") with open(args.output, "wb") as f: - f.write(bytes(screen.main)) f.write(bytes(screen.aux)) + f.write(bytes(screen.main)) if __name__ == "__main__": diff --git a/dither.pyx b/dither.pyx index 4ac1a34..854bc9b 100644 --- a/dither.pyx +++ b/dither.pyx @@ -181,8 +181,8 @@ cdef apply(Dither* dither, screen, int x, int y, Image* image, float[] quant_err @cython.boundscheck(False) @cython.wraparound(False) -def find_nearest_colour(screen, float[::1] pixel_rgb, unsigned char[::1] options_4bit, unsigned char[:, ::1] options_rgb): - cdef int best, dist +cdef find_nearest_colour(screen, float[3] pixel_rgb, unsigned char[::1] options_4bit, unsigned char[:, ::1] options_rgb): + cdef int best, dist, i cdef unsigned char bit4 cdef int best_dist = 2**8 cdef long flat diff --git a/ntsc_colours.py b/ntsc_colours.py new file mode 100644 index 0000000..e2afcf0 --- /dev/null +++ b/ntsc_colours.py @@ -0,0 +1,41 @@ +"""Precomputes all possible colours available via NTSC emulation.""" + +import colour +import numpy as np +from PIL import Image +import screen + + +def main(): + s = screen.DHGR560Screen(palette=None) + bitmap = np.zeros((1, 8), dtype=np.bool) + + colours = {} + unique = set() + + print("SRGB = {") + # For each sequence of 8 pixels, compute the RGB colour of the right-most + # pixel, using NTSC emulation. + for bits in range(256): + for i in range(8): + bitmap[0, i] = bits & (1 << i) + + ntsc = s.bitmap_to_ntsc(bitmap) + last_colour = ntsc[0, -1, :] + colours[bits] = last_colour + unique.add(tuple(last_colour)) + print("%d: np.array((%d, %d, %d))," % ( + bits, last_colour[0], last_colour[1], last_colour[2])) + print("}") + print("# %d unique colours" % len(unique)) + + # Show spectrum of available colours sorted by HSV hue value + im = np.zeros((128, 256 * 16, 3), dtype=np.uint8) + for x, hsv in enumerate(sorted([tuple(colour.RGB_to_HSV(c / 256)) for c in + colours.values()])): + im[0:128, x * 16: (x + 1) * 16, :] = colour.HSV_to_RGB(hsv) * 256 + Image.fromarray(im).show() + + +if __name__ == "__main__": + main() diff --git a/palette.py b/palette.py index 108c056..2b1985f 100644 --- a/palette.py +++ b/palette.py @@ -5,50 +5,45 @@ import image class Palette: - RGB = None + RGB = {} SRGB = None + DOTS = {} + DOTS_TO_INDEX = {} DISTANCES_PATH = None - # Maps palette values to screen dots. Note that these are the same as - # the binary values in reverse order. - DOTS = { - 0: (False, False, False, False), - 1: (True, False, False, False), - 2: (False, True, False, False), - 3: (True, True, False, False), - 4: (False, False, True, False), - 5: (True, False, True, False), - 6: (False, True, True, False), - 7: (True, True, True, False), - 8: (False, False, False, True), - 9: (True, False, False, True), - 10: (False, True, False, True), - 11: (True, True, False, True), - 12: (False, False, True, True), - 13: (True, False, True, True), - 14: (False, True, True, True), - 15: (True, True, True, True) - } - DOTS_TO_4BIT = {} - for k, v in DOTS.items(): - DOTS_TO_4BIT[v] = k + # How many successive screen pixels are used to compute output pixel + # palette index. + PALETTE_DEPTH = None def __init__(self, load_distances=True): if load_distances: # CIE2000 colour distance matrix from 24-bit RGB tuple to 4-bit # palette colour. self.distances = np.memmap(self.DISTANCES_PATH, mode="r+", - dtype=np.uint8, shape=(16777216, 16)) + dtype=np.uint8, shape=(16777216, + len(self.SRGB))) self.RGB = {} for k, v in self.SRGB.items(): self.RGB[k] = (np.clip(image.srgb_to_linear_array(v / 255), 0.0, 1.0) * 255).astype(np.uint8) + # Maps palette values to screen dots. Note that these are the same as + # the binary index values in reverse order. + for i in range(1 << self.PALETTE_DEPTH): + self.DOTS[i] = tuple( + bool(i & (1 << j)) for j in range(self.PALETTE_DEPTH)) + + # Reverse mapping from screen dots to palette index. + self.DOTS_TO_INDEX = {} + for k, v in self.DOTS.items(): + self.DOTS_TO_INDEX[v] = k + class ToHgrPalette(Palette): DISTANCES_PATH = "data/distances_tohgr.data" - + PALETTE_DEPTH = 4 + # Default tohgr/bmp2dhr palette SRGB = { 0: np.array((0, 0, 0)), # Black @@ -72,7 +67,8 @@ class ToHgrPalette(Palette): class OpenEmulatorPalette(Palette): DISTANCES_PATH = "data/distances_openemulator.data" - + PALETTE_DEPTH = 4 + # OpenEmulator SRGB = { 0: np.array((0, 0, 0)), # Black @@ -96,7 +92,8 @@ class OpenEmulatorPalette(Palette): class VirtualIIPalette(Palette): DISTANCES_PATH = "data/distances_virtualii.data" - + PALETTE_DEPTH = 4 + SRGB = { 0: np.array((0, 0, 0)), # Black 8: np.array((231, 36, 66)), # Magenta @@ -117,10 +114,277 @@ class VirtualIIPalette(Palette): } +class NTSCPalette(Palette): + DISTANCES_PATH = 'data/distances_ntsc.data' + PALETTE_DEPTH = 8 + + # Computed using ntsc_colours.py + SRGB = { + 0: np.array((0, 0, 0)), + 1: np.array((0, 0, 62)), + 2: np.array((0, 18, 0)), + 3: np.array((0, 3, 28)), + 4: np.array((44, 14, 0)), + 5: np.array((0, 0, 0)), + 6: np.array((0, 32, 0)), + 7: np.array((0, 18, 0)), + 8: np.array((67, 0, 34)), + 9: np.array((22, 0, 96)), + 10: np.array((0, 0, 0)), + 11: np.array((0, 0, 62)), + 12: np.array((112, 0, 0)), + 13: np.array((67, 0, 34)), + 14: np.array((44, 14, 0)), + 15: np.array((0, 0, 0)), + 16: np.array((24, 54, 131)), + 17: np.array((0, 40, 193)), + 18: np.array((0, 73, 97)), + 19: np.array((0, 58, 159)), + 20: np.array((69, 69, 69)), + 21: np.array((24, 54, 131)), + 22: np.array((1, 87, 35)), + 23: np.array((0, 73, 97)), + 24: np.array((91, 36, 165)), + 25: np.array((47, 22, 227)), + 26: np.array((24, 54, 131)), + 27: np.array((0, 40, 193)), + 28: np.array((136, 50, 103)), + 29: np.array((91, 36, 165)), + 30: np.array((69, 69, 69)), + 31: np.array((24, 54, 131)), + 32: np.array((1, 87, 35)), + 33: np.array((0, 73, 97)), + 34: np.array((0, 105, 1)), + 35: np.array((0, 91, 63)), + 36: np.array((46, 101, 0)), + 37: np.array((1, 87, 35)), + 38: np.array((0, 120, 0)), + 39: np.array((0, 105, 1)), + 40: np.array((69, 69, 69)), + 41: np.array((24, 54, 131)), + 42: np.array((1, 87, 35)), + 43: np.array((0, 73, 97)), + 44: np.array((113, 83, 7)), + 45: np.array((69, 69, 69)), + 46: np.array((46, 101, 0)), + 47: np.array((1, 87, 35)), + 48: np.array((26, 142, 166)), + 49: np.array((0, 127, 228)), + 50: np.array((0, 160, 132)), + 51: np.array((0, 146, 194)), + 52: np.array((70, 156, 104)), + 53: np.array((26, 142, 166)), + 54: np.array((3, 174, 70)), + 55: np.array((0, 160, 132)), + 56: np.array((93, 124, 200)), + 57: np.array((48, 109, 255)), + 58: np.array((26, 142, 166)), + 59: np.array((0, 127, 228)), + 60: np.array((138, 138, 138)), + 61: np.array((93, 124, 200)), + 62: np.array((70, 156, 104)), + 63: np.array((26, 142, 166)), + 64: np.array((113, 83, 7)), + 65: np.array((69, 69, 69)), + 66: np.array((46, 101, 0)), + 67: np.array((1, 87, 35)), + 68: np.array((158, 97, 0)), + 69: np.array((113, 83, 7)), + 70: np.array((91, 116, 0)), + 71: np.array((46, 101, 0)), + 72: np.array((181, 65, 41)), + 73: np.array((136, 50, 103)), + 74: np.array((113, 83, 7)), + 75: np.array((69, 69, 69)), + 76: np.array((226, 79, 0)), + 77: np.array((181, 65, 41)), + 78: np.array((158, 97, 0)), + 79: np.array((113, 83, 7)), + 80: np.array((138, 138, 138)), + 81: np.array((93, 124, 200)), + 82: np.array((70, 156, 104)), + 83: np.array((26, 142, 166)), + 84: np.array((183, 152, 76)), + 85: np.array((138, 138, 138)), + 86: np.array((115, 171, 42)), + 87: np.array((70, 156, 104)), + 88: np.array((205, 120, 172)), + 89: np.array((161, 105, 234)), + 90: np.array((138, 138, 138)), + 91: np.array((93, 124, 200)), + 92: np.array((250, 134, 110)), + 93: np.array((205, 120, 172)), + 94: np.array((183, 152, 76)), + 95: np.array((138, 138, 138)), + 96: np.array((115, 171, 42)), + 97: np.array((70, 156, 104)), + 98: np.array((48, 189, 8)), + 99: np.array((3, 174, 70)), + 100: np.array((160, 185, 0)), + 101: np.array((115, 171, 42)), + 102: np.array((93, 203, 0)), + 103: np.array((48, 189, 8)), + 104: np.array((183, 152, 76)), + 105: np.array((138, 138, 138)), + 106: np.array((115, 171, 42)), + 107: np.array((70, 156, 104)), + 108: np.array((227, 167, 14)), + 109: np.array((183, 152, 76)), + 110: np.array((160, 185, 0)), + 111: np.array((115, 171, 42)), + 112: np.array((140, 225, 173)), + 113: np.array((95, 211, 235)), + 114: np.array((72, 244, 139)), + 115: np.array((28, 229, 201)), + 116: np.array((184, 240, 111)), + 117: np.array((140, 225, 173)), + 118: np.array((117, 255, 77)), + 119: np.array((72, 244, 139)), + 120: np.array((207, 207, 207)), + 121: np.array((162, 193, 255)), + 122: np.array((140, 225, 173)), + 123: np.array((95, 211, 235)), + 124: np.array((252, 221, 145)), + 125: np.array((207, 207, 207)), + 126: np.array((184, 240, 111)), + 127: np.array((140, 225, 173)), + 128: np.array((136, 50, 103)), + 129: np.array((91, 36, 165)), + 130: np.array((69, 69, 69)), + 131: np.array((24, 54, 131)), + 132: np.array((181, 65, 41)), + 133: np.array((136, 50, 103)), + 134: np.array((113, 83, 7)), + 135: np.array((69, 69, 69)), + 136: np.array((203, 32, 137)), + 137: np.array((159, 18, 199)), + 138: np.array((136, 50, 103)), + 139: np.array((91, 36, 165)), + 140: np.array((248, 47, 75)), + 141: np.array((203, 32, 137)), + 142: np.array((181, 65, 41)), + 143: np.array((136, 50, 103)), + 144: np.array((161, 105, 234)), + 145: np.array((116, 91, 255)), + 146: np.array((93, 124, 200)), + 147: np.array((48, 109, 255)), + 148: np.array((205, 120, 172)), + 149: np.array((161, 105, 234)), + 150: np.array((138, 138, 138)), + 151: np.array((93, 124, 200)), + 152: np.array((228, 87, 255)), + 153: np.array((183, 73, 255)), + 154: np.array((161, 105, 234)), + 155: np.array((116, 91, 255)), + 156: np.array((255, 101, 206)), + 157: np.array((228, 87, 255)), + 158: np.array((205, 120, 172)), + 159: np.array((161, 105, 234)), + 160: np.array((138, 138, 138)), + 161: np.array((93, 124, 200)), + 162: np.array((70, 156, 104)), + 163: np.array((26, 142, 166)), + 164: np.array((183, 152, 76)), + 165: np.array((138, 138, 138)), + 166: np.array((115, 171, 42)), + 167: np.array((70, 156, 104)), + 168: np.array((205, 120, 172)), + 169: np.array((161, 105, 234)), + 170: np.array((138, 138, 138)), + 171: np.array((93, 124, 200)), + 172: np.array((250, 134, 110)), + 173: np.array((205, 120, 172)), + 174: np.array((183, 152, 76)), + 175: np.array((138, 138, 138)), + 176: np.array((162, 193, 255)), + 177: np.array((118, 178, 255)), + 178: np.array((95, 211, 235)), + 179: np.array((50, 197, 255)), + 180: np.array((207, 207, 207)), + 181: np.array((162, 193, 255)), + 182: np.array((140, 225, 173)), + 183: np.array((95, 211, 235)), + 184: np.array((230, 174, 255)), + 185: np.array((185, 160, 255)), + 186: np.array((162, 193, 255)), + 187: np.array((118, 178, 255)), + 188: np.array((255, 189, 241)), + 189: np.array((230, 174, 255)), + 190: np.array((207, 207, 207)), + 191: np.array((162, 193, 255)), + 192: np.array((250, 134, 110)), + 193: np.array((205, 120, 172)), + 194: np.array((183, 152, 76)), + 195: np.array((138, 138, 138)), + 196: np.array((255, 148, 48)), + 197: np.array((250, 134, 110)), + 198: np.array((227, 167, 14)), + 199: np.array((183, 152, 76)), + 200: np.array((255, 116, 144)), + 201: np.array((255, 101, 206)), + 202: np.array((250, 134, 110)), + 203: np.array((205, 120, 172)), + 204: np.array((255, 130, 82)), + 205: np.array((255, 116, 144)), + 206: np.array((255, 148, 48)), + 207: np.array((250, 134, 110)), + 208: np.array((255, 189, 241)), + 209: np.array((230, 174, 255)), + 210: np.array((207, 207, 207)), + 211: np.array((162, 193, 255)), + 212: np.array((255, 203, 179)), + 213: np.array((255, 189, 241)), + 214: np.array((252, 221, 145)), + 215: np.array((207, 207, 207)), + 216: np.array((255, 171, 255)), + 217: np.array((255, 156, 255)), + 218: np.array((255, 189, 241)), + 219: np.array((230, 174, 255)), + 220: np.array((255, 185, 213)), + 221: np.array((255, 171, 255)), + 222: np.array((255, 203, 179)), + 223: np.array((255, 189, 241)), + 224: np.array((252, 221, 145)), + 225: np.array((207, 207, 207)), + 226: np.array((184, 240, 111)), + 227: np.array((140, 225, 173)), + 228: np.array((255, 236, 83)), + 229: np.array((252, 221, 145)), + 230: np.array((229, 254, 49)), + 231: np.array((184, 240, 111)), + 232: np.array((255, 203, 179)), + 233: np.array((255, 189, 241)), + 234: np.array((252, 221, 145)), + 235: np.array((207, 207, 207)), + 236: np.array((255, 218, 117)), + 237: np.array((255, 203, 179)), + 238: np.array((255, 236, 83)), + 239: np.array((252, 221, 145)), + 240: np.array((255, 255, 255)), + 241: np.array((232, 255, 255)), + 242: np.array((209, 255, 242)), + 243: np.array((164, 255, 255)), + 244: np.array((255, 255, 214)), + 245: np.array((255, 255, 255)), + 246: np.array((254, 255, 180)), + 247: np.array((209, 255, 242)), + 248: np.array((255, 255, 255)), + 249: np.array((255, 244, 255)), + 250: np.array((255, 255, 255)), + 251: np.array((232, 255, 255)), + 252: np.array((255, 255, 248)), + 253: np.array((255, 255, 255)), + 254: np.array((255, 255, 214)), + 255: np.array((255, 255, 255)), + } + # 84 unique colours + + PALETTES = { 'openemulator': OpenEmulatorPalette, 'virtualii': VirtualIIPalette, 'tohgr': ToHgrPalette, + 'ntsc': NTSCPalette } DEFAULT_PALETTE = 'openemulator' diff --git a/precompute_distance.py b/precompute_distance.py index 2cafa1b..85b3688 100644 --- a/precompute_distance.py +++ b/precompute_distance.py @@ -1,8 +1,9 @@ """Precompute CIE2000 perceptual colour distance matrices. The matrix of delta-E values is computed for all pairs of 24-bit RGB values, -and 4-bit Apple II target palette. This is a 256MB file that is mmapped at -runtime for efficient access. +and Apple II target palette values. This is written out as a file that is +mmapped at runtime for efficient access. For a 16-colour target palette this +file is 256MB; for a 256-colour (NTSC) target palette it is 4GB. """ import argparse @@ -13,7 +14,9 @@ import palette as palette_py import colour.difference import numpy as np -COLOURS = 256 +RGB_LEVELS = 256 +# Largest possible value of delta_E_CIE2000 between two RGB values +DELTA_E_MAX = 120 # TODO: fine-tune def rgb_to_lab(rgb: np.ndarray): @@ -25,21 +28,29 @@ def rgb_to_lab(rgb: np.ndarray): def all_lab_colours(): - all_rgb = np.array(tuple(np.ndindex(COLOURS, COLOURS, COLOURS)), + all_rgb = np.array(tuple(np.ndindex(RGB_LEVELS, RGB_LEVELS, RGB_LEVELS)), dtype=np.uint8) return rgb_to_lab(all_rgb) -def nearest_colours(palette, all_lab): - diffs = np.empty((COLOURS ** 3, 16), dtype=np.float32) +def nearest_colours(palette, all_lab, diffs): + palette_size = len(palette) + palette_labs = np.empty((palette_size, 3), dtype=np.float) + for i, palette_rgb in palette.RGB.items(): + palette_labs[i, :] = rgb_to_lab(palette_rgb) - for i, palette_rgb in sorted(palette.RGB.items()): - print("...palette colour %d" % i) - palette_lab = rgb_to_lab(palette_rgb) - diffs[:, i] = colour.difference.delta_E_CIE2000(all_lab, palette_lab) - - norm = np.max(diffs) - return (diffs / norm * 255).astype(np.uint8) + print("Computing all 24-bit palette diffs:") + for i in range(palette_size): + print(" %d/%d" % (i, palette_size)) + # Compute all palette diffs for a block of 65536 successive RGB + # source values at once, which bounds the memory use while also writing + # contiguously to the mmapped array. + diffs[i * (1 << 16):(i + 1) * (1 << 16), :] = ( + colour.difference.delta_E_CIE2000( + all_lab[i * (1 << 16):(i + 1) * ( + 1 << 16)].reshape((1 << 16, 1, 3)), + palette_labs.reshape((1, palette_size, 3))) / DELTA_E_MAX * + 255).astype(np.uint8) def main(): @@ -60,16 +71,15 @@ def main(): print("Precomputing matrix of all 24-bit LAB colours") all_lab = all_lab_colours() for palette_name in palette_names: - print("Processing palette %s" % palette_name) + print("Creating distance file for palette %s" % palette_name) palette = palette_py.PALETTES[palette_name](load_distances=False) try: os.mkdir(os.path.dirname(palette.DISTANCES_PATH)) except FileExistsError: pass - n = nearest_colours(palette, all_lab) out = np.memmap(filename=palette.DISTANCES_PATH, mode="w+", - dtype=np.uint8, shape=n.shape) - out[:] = n[:] + dtype=np.uint8, shape=(RGB_LEVELS ** 3, len(palette))) + nearest_colours(palette, all_lab, out) if __name__ == "__main__": diff --git a/screen.py b/screen.py index 1cffc54..5a83d04 100644 --- a/screen.py +++ b/screen.py @@ -3,6 +3,8 @@ import numpy as np import palette as palette_py +# TODO: rename "4bit" variable naming now that we also have palettes with 8 bit +# depth. class Screen: X_RES = None @@ -72,7 +74,7 @@ class Screen: pixel = [False, False, False, False] for x in range(560): pixel[x % 4] = bitmap[y, x] - dots = self.palette.DOTS_TO_4BIT[tuple(pixel)] + dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] image_rgb[y, x, :] = self.palette.RGB[dots] return image_rgb @@ -81,12 +83,12 @@ class Screen: raise NotImplementedError @staticmethod - def _sin(pos, phase0=3): + def _sin(pos, phase0=4): x = pos % 12 + phase0 return 8 * np.sin(x * 2 * np.pi / 12) @staticmethod - def _cos(pos, phase0=3): + def _cos(pos, phase0=4): x = pos % 12 + phase0 return 8 * np.cos(x * 2 * np.pi / 12) @@ -128,17 +130,18 @@ class Screen: ib = contrast * -1.012984e-6 * saturation / i_width qb = contrast * 1.667217e-6 * saturation / q_width - out_rgb = np.empty((192, 560 * 3, 3), dtype=np.uint8) - for y in range(self.Y_RES): + out_rgb = np.empty((bitmap.shape[0], bitmap.shape[1] * 3, 3), + dtype=np.uint8) + for y in range(bitmap.shape[0]): ysum = 0 isum = 0 qsum = 0 line = np.repeat(bitmap[y], 3) - color = y // (192//16) - line = np.repeat(np.tile((color & 1, color & 2, color & 4, - color & 8), 140), 3) - for x in range(560 * 3): + # color = y // (192//16) + # line = np.repeat(np.tile((color & 1, color & 2, color & 4, + # color & 8), 140), 3) + for x in range(bitmap.shape[1] * 3): ysum += self._read(line, x) - self._read(line, x - y_width) isum += self._read(line, x) * self._cos(x) - self._read( line, x - i_width) * self._cos((x - i_width)) @@ -182,7 +185,7 @@ class DHGR140Screen(Screen): class DHGR560Screen(Screen): - """DHGR screen including colour fringing.""" + """DHGR screen including colour fringing and 4 pixel chroma bleed.""" X_RES = 560 Y_RES = 192 X_PIXEL_WIDTH = 1 @@ -205,8 +208,60 @@ class DHGR560Screen(Screen): other_dots = list(last_dots) other_dots[x % 4] = not other_dots[x % 4] other_dots = tuple(other_dots) - other_pixel_4bit = self.palette.DOTS_TO_4BIT[other_dots] + other_pixel_4bit = self.palette.DOTS_TO_INDEX[other_dots] + return (np.array([last_pixel_4bit, other_pixel_4bit], dtype=np.uint8), + np.array([self.palette.RGB[last_pixel_4bit], + self.palette.RGB[other_pixel_4bit]], dtype=np.uint8)) + + +# TODO: refactor to share implementation with DHGR560Screen +class DHGR560NTSCScreen(Screen): + """DHGR screen including colour fringing and 8 pixel chroma bleed.""" + X_RES = 560 + Y_RES = 192 + X_PIXEL_WIDTH = 1 + + def _image_to_bitmap(self, image_4bit: np.ndarray) -> 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_4bit[y, x] + dots = self.palette.DOTS[pixel] + phase = x % 4 + bitmap[y, x] = dots[4 + phase] + return bitmap + + def bitmap_to_image_rgb(self, bitmap: np.ndarray) -> np.ndarray: + """Convert our 2-bit bitmap image into a RGB image. + + Colour at every pixel is determined by the value of a 8-bit sliding + 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) + for y in range(self.Y_RES): + pixel = [False, False, False, False, False, False, False, False] + for x in range(560): + pixel[x % 4] = pixel[x % 4 + 4] + pixel[x % 4 + 4] = bitmap[y, x] + dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] + image_rgb[y, x, :] = self.palette.RGB[dots] + return image_rgb + + def pixel_palette_options(self, last_pixel_4bit, x: int): + # The two available 8-bit pixel colour choices are given by: + # - Rotating the pixel value from the current x % 4 + 4 position to + # x % 4 + # - choosing 0 and 1 for the new values of x % 4 + 4 + next_dots0 = list(self.palette.DOTS[last_pixel_4bit]) + next_dots1 = list(next_dots0) + next_dots0[x % 4] = next_dots0[x % 4 + 4] + next_dots0[x % 4 + 4] = False + next_dots1[x % 4] = next_dots1[x % 4 + 4] + next_dots1[x % 4 + 4] = True + pixel_4bit_0 = self.palette.DOTS_TO_INDEX[tuple(next_dots0)] + pixel_4bit_1 = self.palette.DOTS_TO_INDEX[tuple(next_dots1)] return ( - np.array([last_pixel_4bit, other_pixel_4bit], dtype=np.uint8), - np.array([self.palette.RGB[last_pixel_4bit], - self.palette.RGB[other_pixel_4bit]], dtype=np.uint8)) + 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))