From 8cfee55b1dc157f85cf16592bf51fd328b73b77f Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 2 Nov 2021 13:40:32 +0000 Subject: [PATCH] Get rid of support for 140px mode, it was only useful as a demo of why other converters have the wrong basic approach. --- convert.py | 33 +++------ dither.pyx | 103 +++++++++------------------ screen.py | 206 ++++++++++++++--------------------------------------- 3 files changed, 95 insertions(+), 247 deletions(-) diff --git a/convert.py b/convert.py index 3e3704a..77a7222 100644 --- a/convert.py +++ b/convert.py @@ -40,16 +40,6 @@ def main(): parser.add_argument( '--show-output', action=argparse.BooleanOptionalAction, default=True, help="Whether to show the output image after conversion.") - parser.add_argument( - '--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 (default: 560)") - ) parser.add_argument( '--palette', type=str, choices=list(set(palette_py.PALETTES.keys())), default=palette_py.DEFAULT_PALETTE, @@ -72,19 +62,12 @@ def main(): args = parser.parse_args() 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 + if args.palette == "ntsc": + # TODO: palette depth should be controlled by Palette not Screen + screen = screen_py.DHGR560NTSCScreen(palette) else: - if args.palette == "ntsc": - # TODO: palette depth should be controlled by Palette not Screen - screen = screen_py.DHGR560NTSCScreen(palette) - else: - screen = screen_py.DHGR560Screen(palette) - lookahead = args.lookahead + screen = screen_py.DHGR560Screen(palette) + lookahead = args.lookahead # Conversion matrix from RGB to CAM16UCS colour values. Indexed by # 24-bit RGB value @@ -93,7 +76,7 @@ def main(): # Open and resize source image image = image_py.open(args.input) if args.show_input: - image_py.resize(image, screen.NATIVE_X_RES, screen.NATIVE_Y_RES * 2, + image_py.resize(image, screen.X_RES, screen.Y_RES * 2, srgb_output=True).show() rgb = np.array( image_py.resize(image, screen.X_RES, screen.Y_RES, @@ -114,8 +97,8 @@ def main(): 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, screen.NATIVE_X_RES, - screen.NATIVE_Y_RES * 2, srgb_output=True) + out_image = image_py.resize(out_image, screen.X_RES, screen.Y_RES * 2, + srgb_output=True) if args.show_output: out_image.show() diff --git a/dither.pyx b/dither.pyx index d843979..f31d294 100644 --- a/dither.pyx +++ b/dither.pyx @@ -89,7 +89,7 @@ cdef inline unsigned char shift_pixel_window( cdef int dither_lookahead(Dither* dither, float[:, :, ::1] palette_cam16, float[:, :, ::1] palette_rgb, float[:, :, ::1] image_rgb, int x, int y, int lookahead, unsigned char last_pixels, int x_res, float[:,::1] rgb_to_cam16ucs, unsigned char palette_depth) nogil: - cdef int i, j, k + cdef int candidate_pixels, i, j cdef float[3] quant_error cdef int best cdef float best_error = 2**31-1 @@ -110,41 +110,42 @@ cdef int dither_lookahead(Dither* dither, float[:, :, ::1] palette_cam16, float[ # given pixel (dependent on the state already chosen for pixels to the left), we need to look beyond local minima. # i.e. it might be better to make a sub-optimal choice for this pixel if it allows access to much better pixel # colours at later positions. - for i in range(1 << lookahead): + for candidate_pixels in range(1 << lookahead): # Working copy of input pixels - for j in range(xxr - x): - for k in range(3): - lah_image_rgb[j * lah_shape2 + k] = image_rgb[y, x+j, k] + for i in range(xxr - x): + for j in range(3): + lah_image_rgb[i * lah_shape2 + j] = image_rgb[y, x+i, j] total_error = 0 - for j in range(xxr - x): - xl = dither_bounds_xl(dither, j) - xr = dither_bounds_xr(dither, xxr - x, j) - phase = (x + j) % 4 + # Apply dithering to lookahead horizon or edge of screen + for i in range(xxr - x): + xl = dither_bounds_xl(dither, i) + xr = dither_bounds_xr(dither, xxr - x, i) + phase = (x + i) % 4 next_pixels = shift_pixel_window( - last_pixels, next_pixels=i, shift_right_by=j+1, window_width=palette_depth) + last_pixels, next_pixels=candidate_pixels, shift_right_by=i+1, window_width=palette_depth) - # We don't update the input at position x (since we've already chosen - # fixed outputs), but we do propagate quantization errors to positions >x - # so we can compensate for how good/bad these choices were. i.e. the - # options_rgb choices are fixed, but we can still distribute quantization error - # from having made these choices, in order to compute the total error. - for k in range(3): - quant_error[k] = lah_image_rgb[j * lah_shape2 + k] - palette_rgb[next_pixels, phase, k] - apply_one_line(dither, xl, xr, j, lah_image_rgb, lah_shape2, quant_error) + # We don't update the input at position x (since we've already chosen fixed outputs), but we do propagate + # quantization errors to positions >x so we can compensate for how good/bad these choices were. i.e. the + # next_pixels choices are fixed, but we can still distribute quantization error from having made these + # choices, in order to compute the total error. + for j in range(3): + quant_error[j] = lah_image_rgb[i * lah_shape2 + j] - palette_rgb[next_pixels, phase, j] + apply_one_line(dither, xl, xr, i, lah_image_rgb, lah_shape2, quant_error) lah_cam16ucs = convert_rgb_to_cam16ucs( - rgb_to_cam16ucs, lah_image_rgb[j*lah_shape2], lah_image_rgb[j*lah_shape2+1], - lah_image_rgb[j*lah_shape2+2]) + rgb_to_cam16ucs, lah_image_rgb[i*lah_shape2], lah_image_rgb[i*lah_shape2+1], + lah_image_rgb[i*lah_shape2+2]) total_error += colour_distance_squared(lah_cam16ucs, palette_cam16[next_pixels, phase]) if total_error >= best_error: + # No need to continue break if total_error < best_error: best_error = total_error - best = i + best = candidate_pixels free(lah_image_rgb) return best @@ -215,36 +216,6 @@ cdef void apply(Dither* dither, int x_res, int y_res, int x, int y, float[:,:,:: for k in range(3): image[i,j,k] = clip(image[i,j,k] + error_fraction * quant_error[k], 0, 1) -# Compute closest colour from array of candidate n-bit colour palette values. -# -# Args: -# pixel_rgb: source RGB colour value to be matched -# options_nbit: array of candidate n-bit colour palette values -# distances: matrix of (24-bit RGB value, n-bit colour value) perceptual colour differences -# -# Returns: -# index of options_nbit entry having lowest distance value -# -@cython.boundscheck(False) -@cython.wraparound(False) -cdef unsigned char find_nearest_colour(float[::1] pixel_rgb, unsigned char[::1] options_nbit, - unsigned char[:, ::1] distances): - - cdef int best, dist - cdef unsigned char bit4 - cdef int best_dist = 2**8 - cdef long flat - - for i in range(options_nbit.shape[0]): - flat = (pixel_rgb[0] << 16) + (pixel_rgb[1] << 8) + pixel_rgb[2] - bit4 = options_nbit[i] - dist = distances[flat, bit4] - if dist < best_dist: - best_dist = dist - best = i - - return options_nbit[best] - # Dither a source image # @@ -259,7 +230,8 @@ cdef unsigned char find_nearest_colour(float[::1] pixel_rgb, unsigned char[::1] # @cython.boundscheck(False) @cython.wraparound(False) -def dither_image(screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsigned char verbose, float[:,::1] rgb_to_cam16ucs): +def dither_image( + screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsigned char verbose, float[:,::1] rgb_to_cam16ucs): cdef int y, x, i, j, k # cdef float[3] input_pixel_rgb cdef float[3] quant_error @@ -307,23 +279,16 @@ def dither_image(screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsi print("%d/%d" % (y, yres)) output_pixel_nbit = 0 for x in range(xres): - #for i in range(3): - # input_pixel_rgb[i] = image_rgb[y,x,i] - if lookahead: - # Compute all possible 2**N choices of n-bit pixel colours for positions x .. x + lookahead - # lookahead_palette_choices_nbit = lookahead_options(lookahead, output_pixel_nbit) - # Apply error diffusion for each of these 2**N choices, and compute which produces the closest match - # to the source image over the succeeding N pixels - best_next_pixels = dither_lookahead( - &cdither, palette_cam16, palette_rgb, image_rgb, x, y, lookahead, output_pixel_nbit, xres, - rgb_to_cam16ucs, palette_depth) - # Apply best choice for next 1 pixel - output_pixel_nbit = shift_pixel_window( - output_pixel_nbit, best_next_pixels, shift_right_by=1, window_width=palette_depth) - #else: - # # Choose the closest colour among the available n-bit palette options - # palette_choices_nbit = screen.pixel_palette_options(output_pixel_nbit, x) - # output_pixel_nbit = find_nearest_colour(input_pixel_rgb, palette_choices_nbit, distances) + # Compute all possible 2**N choices of n-bit pixel colours for positions x .. x + lookahead + # lookahead_palette_choices_nbit = lookahead_options(lookahead, output_pixel_nbit) + # Apply error diffusion for each of these 2**N choices, and compute which produces the closest match + # to the source image over the succeeding N pixels + best_next_pixels = dither_lookahead( + &cdither, palette_cam16, palette_rgb, image_rgb, x, y, lookahead, output_pixel_nbit, xres, + rgb_to_cam16ucs, palette_depth) + # Apply best choice for next 1 pixel + output_pixel_nbit = shift_pixel_window( + output_pixel_nbit, best_next_pixels, shift_right_by=1, window_width=palette_depth) # Apply error diffusion from chosen output pixel value for i in range(3): diff --git a/screen.py b/screen.py index 0ad8d0f..ede0592 100644 --- a/screen.py +++ b/screen.py @@ -4,13 +4,9 @@ import numpy as np import palette as palette_py -class Screen: - X_RES = None - Y_RES = None - X_PIXEL_WIDTH = None - - NATIVE_X_RES = 560 - NATIVE_Y_RES = 192 +class DHGRScreen: + X_RES = 560 + Y_RES = 192 def __init__(self, palette: palette_py.Palette): self.main = np.zeros(8192, dtype=np.uint8) @@ -42,9 +38,9 @@ class Screen: # 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) + (self.Y_RES, self.X_RES // 14), dtype=np.uint8) aux_col = np.zeros( - (self.Y_RES, self.X_RES * self.X_PIXEL_WIDTH // 14), dtype=np.uint8) + (self.Y_RES, self.X_RES // 14), dtype=np.uint8) for byte_offset in range(80): column = np.zeros(self.Y_RES, dtype=np.uint8) for bit in range(7): @@ -69,19 +65,62 @@ class Screen: window indexed by x % 4, which gives the index into our 16-colour RGB palette. """ - image_rgb = np.empty((self.NATIVE_Y_RES, self.NATIVE_X_RES, 3), + image_rgb = np.empty((self.Y_RES, self.X_RES, 3), dtype=np.uint8) for y in range(self.Y_RES): pixel = [False, False, False, False] - for x in range(self.NATIVE_X_RES): + for x in range(self.X_RES): pixel[x % 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_nbit, x: int): - """Returns available colours for given x pos and n-bit colour of x-1""" - raise NotImplementedError + +class DHGR560Screen(DHGRScreen): + """DHGR screen including colour fringing and 4 pixel chroma bleed.""" + + def _image_to_bitmap(self, image_nbit: 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_nbit[y, x] + dots = self.palette.DOTS[pixel] + phase = x % 4 + bitmap[y, x] = dots[phase] + return bitmap + + +# TODO: refactor to share implementation with DHGR560Screen +class DHGR560NTSCScreen(DHGRScreen): + """DHGR screen including colour fringing and 8 pixel chroma bleed.""" + + # XXX image_nbit is MSB (x, ... x-7) LSB pixel values? + + def _image_to_bitmap(self, image_nbit: 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_nbit[y, x] + bitmap[y, x] = pixel >> 7 + return bitmap + + # TODO: unify with parent + 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((self.Y_RES, self.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(self.X_RES): + pixel = pixel[1:] + [bitmap[y, x]] + dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] + image_rgb[y, x, :] = self.palette.RGB[dots, x % 4] + return image_rgb @staticmethod def _sin(pos, phase0=0): @@ -99,145 +138,6 @@ class Screen: return 1 if line[pos] else 0 - -class DHGR140Screen(Screen): - """DHGR screen ignoring colour fringing, i.e. treating as 140x192x16.""" - - X_RES = 140 - Y_RES = 192 - X_PIXEL_WIDTH = 4 - - def _image_to_bitmap(self, image_nbit: np.ndarray) -> 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_nbit[y, x] - dots = self.palette.DOTS[pixel] - bitmap[y, x * self.X_PIXEL_WIDTH:( - (x + 1) * self.X_PIXEL_WIDTH)] = dots - return bitmap - - def pixel_palette_options(self, last_pixel_nbit, x: int): - # All 16 colour choices are available at every x position. - return np.array(list(self.palette.RGB.keys()), dtype=np.uint8) - - -class DHGR560Screen(Screen): - """DHGR screen including colour fringing and 4 pixel chroma bleed.""" - X_RES = 560 - Y_RES = 192 - X_PIXEL_WIDTH = 1 - - def _image_to_bitmap(self, image_nbit: 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_nbit[y, x] - dots = self.palette.DOTS[pixel] - phase = x % 4 - bitmap[y, x] = dots[phase] - return bitmap - - def pixel_palette_options(self, last_pixel_nbit, x: int): - last_dots = self.palette.DOTS[last_pixel_nbit][1:] + [None] - - # rearrange into palette order - next_dots = [None] * 8 - for i in range(4): - next_dots[(i - x) % 4] = last_dots[i] - next_dots[(i - x) % 4 + 4] = last_dots[i + 4] - - # XXX wrong - - assert next_dots[(3 - x) % 4 + 4] is None - # print(x, last_dots, next_dots) - - next_dots[(3 - x) % 4 + 4] = False - next_pixel_nbit_0 = self.palette.DOTS_TO_INDEX[next_dots] - - next_dots[(3 - x) % 4 + 4] = True - next_pixel_nbit_1 = self.palette.DOTS_TO_INDEX[next_dots] - return np.array([next_pixel_nbit_0, next_pixel_nbit_1], 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_nbit: 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_nbit[y, x] - #dots = self.palette.DOTS[pixel] - #phase = x % 4 - bitmap[y, x] = pixel >> 7 # 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((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(self.NATIVE_X_RES): - # pixel[x % 4] = pixel[x % 4 + 4] - # pixel[x % 4 + 4] = bitmap[y, x] - pixel = pixel[1:] + [bitmap[y, x]] - dots = self.palette.DOTS_TO_INDEX[tuple(pixel)] - image_rgb[y, x, :] = self.palette.RGB[dots, x % 4] - return image_rgb - - def pixel_palette_options(self, last_pixel_nbit): - # # 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_nbit]) - # 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_nbit_0 = self.palette.DOTS_TO_INDEX[tuple(next_dots0)] - # pixel_nbit_1 = self.palette.DOTS_TO_INDEX[tuple(next_dots1)] - # return np.array([pixel_nbit_0, pixel_nbit_1], dtype=np.uint8) - #next_dots = last_dots[1:] # list(self.palette.DOTS[ - # last_pixel_nbit][1:]) - #return np.array([ - # self.palette.DOTS_TO_INDEX[tuple(last_dots + [False])], - # self.palette.DOTS_TO_INDEX[tuple(next_dots + [True])]], - # dtype=np.uint8) - - return np.array(last_pixel_nbit >> 1, (last_pixel_nbit >> 1) + 1, - dtype=np.uint8) - - # # rearrange into palette order - # next_dots = [None] * 8 - # for i in range(4): - # next_dots[i] = last_dots[(i - x) % 4] - # next_dots[i + 4] = last_dots[(i - x) % 4 + 4] - # - # assert next_dots[(3 + x) % 4 + 4] is None - # # print(x, last_dots, next_dots) - # - # next_dots[(3 + x) % 4 + 4] = False - # next_pixel_nbit_0 = self.palette.DOTS_TO_INDEX[tuple(next_dots)] - # - # next_dots[(3 + x) % 4 + 4] = True - # next_pixel_nbit_1 = self.palette.DOTS_TO_INDEX[tuple(next_dots)] - # return np.array([next_pixel_nbit_0, next_pixel_nbit_1], - # dtype=np.uint8) - def bitmap_to_ntsc(self, bitmap: np.ndarray) -> np.ndarray: y_width = 12 u_width = 24