diff --git a/dither.pyx b/dither.pyx index 3d7a480..8c9ec30 100644 --- a/dither.pyx +++ b/dither.pyx @@ -9,7 +9,9 @@ from libc.stdlib cimport malloc, free # TODO: use a cdef class +# C representation of dither_pattern.DitherPattern data, for efficient access. cdef struct Dither: + float* pattern # Flattened dither pattern int x_shape int y_shape @@ -21,24 +23,29 @@ cdef float clip(float a, float min_value, float max_value) nogil: return min(max(a, min_value), max_value) +# Compute left-hand bounding box for dithering at horizontal position x. cdef int dither_bounds_xl(Dither *dither, int x) nogil: cdef int el = max(dither.x_origin - x, 0) cdef int xl = x - dither.x_origin + el return xl +#Compute right-hand bounding box for dithering at horizontal position x. cdef int dither_bounds_xr(Dither *dither, int x_res, int x) nogil: cdef int er = min(dither.x_shape, x_res - x) cdef int xr = x - dither.x_origin + er return xr +# Compute upper bounding box for dithering at vertical position y. cdef int dither_bounds_yt(Dither *dither, int y) nogil: cdef int et = max(dither.y_origin - y, 0) cdef int yt = y - dither.y_origin + et return yt + +# Compute lower bounding box for dithering at vertical position y. cdef int dither_bounds_yb(Dither *dither, int y_res, int y) nogil: cdef int eb = min(dither.y_shape, y_res - y) cdef int yb = y - dither.y_origin + eb @@ -49,11 +56,22 @@ cdef int dither_bounds_yb(Dither *dither, int y_res, int y) nogil: @cython.wraparound(False) @functools.lru_cache(None) def lookahead_options(object screen, int lookahead, unsigned char last_pixel_nbit, int x): + """Compute all possible n-bit palette values for upcoming pixels, given x coord and state of n pixels to the left. + + Args: + screen: python screen.Screen object + lookahead: how many pixels to lookahead + last_pixel_nbit: n-bit value representing n pixels to left of current position, which determine available + colours. + x: current x position + + Returns: matrix of size (2**lookahead, lookahead) containing all 2**lookahead possible vectors of n-bit palette + values accessible at positions x .. x + lookahead + """ cdef unsigned char[:, ::1] options_nbit = np.empty((2 ** lookahead, lookahead), dtype=np.uint8) cdef int i, j, xx, p cdef unsigned char output_pixel_nbit cdef unsigned char[::1] palette_choices_nbit - cdef unsigned char[:, ::1] palette_choices_rgb cdef object palette = screen.palette cdef dict palette_rgb = palette.RGB @@ -61,27 +79,36 @@ def lookahead_options(object screen, int lookahead, unsigned char last_pixel_nbi output_pixel_nbit = last_pixel_nbit for j in range(lookahead): xx = x + j + # Two possible n-bit palette choices at position xx, given state of n pixels to left. # TODO: port screen.py to pyx - palette_choices_nbit, _ = screen.pixel_palette_options(output_pixel_nbit, xx) + palette_choices_nbit = screen.pixel_palette_options(output_pixel_nbit, xx) output_pixel_nbit = palette_choices_nbit[(i & (1 << j)) >> j] options_nbit[i, j] = output_pixel_nbit return options_nbit +# Look ahead a number of pixels and compute choice for next pixel with lowest total squared error after dithering. +# +# Args: +# dither: error diffusion pattern to apply +# palette_rgb: matrix of all n-bit colour palette RGB values +# image_rgb: RGB image in the process of dithering +# x: current horizontal screen position +# y: current vertical screen position +# options_nbit: matrix of (2**lookahead, lookahead) possible n-bit colour choices at positions x .. x + lookahead +# lookahead: how many horizontal pixels to look ahead +# distances: matrix of (24-bit RGB, n-bit palette) perceptual colour distances +# x_res: horizontal screen resolution +# +# Returns: index from 0 .. 2**lookahead into options_nbit representing best available choice for position (x,y) +# @cython.boundscheck(False) @cython.wraparound(False) cdef int dither_lookahead(Dither* dither, float[:, ::1] palette_rgb, float[:, :, ::1] image_rgb, int x, int y, unsigned char[:, ::1] options_nbit, int lookahead, unsigned char[:, ::1] distances, int x_res): cdef int i, j, k, l - - # Don't bother dithering past the lookahead horizon or edge of screen. - cdef int xxr = min(x + lookahead, x_res) - cdef int lah_shape1 = xxr - x - cdef int lah_shape2 = 3 - cdef float *lah_image_rgb = malloc(lah_shape1 * lah_shape2 * sizeof(float)) - cdef float[3] quant_error cdef unsigned char bit4 cdef int best @@ -90,6 +117,17 @@ cdef int dither_lookahead(Dither* dither, float[:, ::1] palette_rgb, cdef long flat, dist cdef long r, g, b + # Don't bother dithering past the lookahead horizon or edge of screen. + cdef int xxr = min(x + lookahead, x_res) + cdef int lah_shape1 = xxr - x + cdef int lah_shape2 = 3 + cdef float *lah_image_rgb = malloc(lah_shape1 * lah_shape2 * sizeof(float)) + + # For each 2**lookahead possibilities for the on/off state of the next lookahead pixels, apply error diffusion + # and compute the total squared error to the source image. Since we only have two possible colours for each + # 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): # Working copy of input pixels for j in range(xxr - x): @@ -127,8 +165,20 @@ cdef int dither_lookahead(Dither* dither, float[:, ::1] palette_rgb, return best +# Perform error diffusion to a single image row. +# +# Args: +# dither: dither pattern to apply +# xl: lower x bounding box +# xr: upper x bounding box +# x: starting horizontal position to apply error diffusion +# image: array of shape (image_shape1, 3) representing RGB pixel data for a single image line, to be mutated. +# image_shape1: horizontal dimension of image +# quant_error: RGB quantization error to be diffused +# cdef void apply_one_line(Dither* dither, int xl, int xr, int x, float[] image, int image_shape1, float[] quant_error) nogil: + cdef int i, j cdef float error_fraction @@ -138,9 +188,21 @@ cdef void apply_one_line(Dither* dither, int xl, int xr, int x, float[] image, i image[i * image_shape1 + j] = clip(image[i * image_shape1 + j] + error_fraction * quant_error[j], 0, 255) +# Perform error diffusion across multiple image rows. +# +# Args: +# dither: dither pattern to apply +# x_res: horizontal image resolution +# y_res: vertical image resolution +# x: starting horizontal position to apply error diffusion +# y: starting vertical position to apply error diffusion +# image: RGB pixel data, to be mutated +# quant_error: RGB quantization error to be diffused +# @cython.boundscheck(False) @cython.wraparound(False) cdef void apply(Dither* dither, int x_res, int y_res, int x, int y, float[:,:,::1] image, float[] quant_error): + cdef int i, j, k cdef int yt = dither_bounds_yt(dither, y) @@ -160,10 +222,21 @@ cdef void apply(Dither* dither, int x_res, int y_res, int x, int y, float[:,:,:: image[i,j,k] = clip(image[i,j,k] + error_fraction * quant_error[k], 0, 255) +# 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] options_rgb, unsigned char[:, ::1] distances): + unsigned char[:, ::1] distances): + cdef int best, dist cdef unsigned char bit4 cdef int best_dist = 2**8 @@ -180,6 +253,17 @@ cdef unsigned char find_nearest_colour(float[::1] pixel_rgb, unsigned char[::1] return options_nbit[best] +# Dither a source image +# +# Args: +# screen: screen.Screen object +# image_rgb: input RGB image +# dither: dither_pattern.DitherPattern to apply during dithering +# lookahead: how many x positions to look ahead to optimize colour choices +# verbose: whether to output progress during image conversion +# +# Returns: tuple of n-bit output image array and RGB output image array +# @cython.boundscheck(False) @cython.wraparound(False) def dither_image(screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsigned char verbose): @@ -190,7 +274,6 @@ def dither_image(screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsi cdef float[:, :, ::1] options_rgb cdef unsigned char [:, ::1] lookahead_palette_choices_nbit cdef unsigned char [::1] palette_choices_nbit - cdef unsigned char [:, ::1] palette_choices_rgb cdef unsigned char output_pixel_nbit cdef float[::1] output_pixel_rgb @@ -228,20 +311,27 @@ def dither_image(screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsi 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(screen, lookahead, output_pixel_nbit, x % 4) + + # 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_idx = dither_lookahead( &cdither, palette_rgb, image_rgb, x, y, lookahead_palette_choices_nbit, lookahead, distances, xres) output_pixel_nbit = lookahead_palette_choices_nbit[best_idx, 0] else: - palette_choices_nbit, palette_choices_rgb = screen.pixel_palette_options(output_pixel_nbit, x) - output_pixel_nbit = find_nearest_colour( - input_pixel_rgb, palette_choices_nbit, palette_choices_rgb, distances) + # 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) + + # Apply error diffusion from chosen output pixel value output_pixel_rgb = palette_rgb[output_pixel_nbit] for i in range(3): quant_error[i] = input_pixel_rgb[i] - output_pixel_rgb[i] image_nbit[y, x] = output_pixel_nbit apply(&cdither, xres, yres, x, y, image_rgb, quant_error) + for i in range(3): image_rgb[y, x, i] = output_pixel_rgb[i] diff --git a/screen.py b/screen.py index 18f1f7f..f6e4054 100644 --- a/screen.py +++ b/screen.py @@ -120,9 +120,7 @@ class DHGR140Screen(Screen): 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), - np.array(list(self.palette.RGB.values()), dtype=np.uint8)) + return np.array(list(self.palette.RGB.keys()), dtype=np.uint8) class DHGR560Screen(Screen): @@ -150,9 +148,7 @@ class DHGR560Screen(Screen): other_dots[x % 4] = not other_dots[x % 4] other_dots = tuple(other_dots) other_pixel_nbit = self.palette.DOTS_TO_INDEX[other_dots] - return (np.array([last_pixel_nbit, other_pixel_nbit], dtype=np.uint8), - np.array([self.palette.RGB[last_pixel_nbit], - self.palette.RGB[other_pixel_nbit]], dtype=np.uint8)) + return np.array([last_pixel_nbit, other_pixel_nbit], dtype=np.uint8) # TODO: refactor to share implementation with DHGR560Screen @@ -202,10 +198,7 @@ class DHGR560NTSCScreen(Screen): 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), - np.array([self.palette.RGB[pixel_nbit_0], - self.palette.RGB[pixel_nbit_1]], dtype=np.uint8)) + return np.array([pixel_nbit_0, pixel_nbit_1], dtype=np.uint8) def bitmap_to_ntsc(self, bitmap: np.ndarray) -> np.ndarray: y_width = 12