Get rid of support for 140px mode, it was only useful as a demo of why

other converters have the wrong basic approach.
This commit is contained in:
kris 2021-11-02 13:40:32 +00:00
parent d442baf1f1
commit 8cfee55b1d
3 changed files with 95 additions and 247 deletions

View File

@ -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()

View File

@ -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 = (<long>pixel_rgb[0] << 16) + (<long>pixel_rgb[1] << 8) + <long>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):

206
screen.py
View File

@ -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