Add a slightly hacked up snapshot of ii-pix to do inline DHGR conversions

This commit is contained in:
kris 2023-01-21 22:08:12 +00:00
parent 4529bc3c74
commit 1149943d71
11 changed files with 2028 additions and 0 deletions

View File

@ -0,0 +1,5 @@
cdef float clip(float a, float min_value, float max_value) nogil
cdef float[::1] convert_rgb_to_cam16ucs(float[:, ::1] rgb_to_cam16ucs, float r, float g, float b) nogil
cdef double colour_distance_squared(float[::1] colour1, float[::1] colour2) nogil

View File

@ -0,0 +1,23 @@
# cython: infer_types=True
# cython: profile=False
# cython: boundscheck=False
# cython: wraparound=False
cdef float clip(float a, float min_value, float max_value) nogil:
"""Clip a value between min_value and max_value inclusive."""
return min(max(a, min_value), max_value)
cdef inline float[::1] convert_rgb_to_cam16ucs(float[:, ::1] rgb_to_cam16ucs, float r, float g, float b) nogil:
"""Converts floating point (r,g,b) valueto 3-tuple in CAM16UCS colour space, via 24-bit RGB lookup matrix."""
cdef unsigned int rgb_24bit = (<unsigned int>(r*255) << 16) + (<unsigned int>(g*255) << 8) + <unsigned int>(b*255)
return rgb_to_cam16ucs[rgb_24bit]
cdef inline double colour_distance_squared(float[::1] colour1, float[::1] colour2) nogil:
"""Computes Euclidean squared distance between two floating-point colour 3-tuples."""
return (colour1[0] - colour2[0]) ** 2 + (colour1[1] - colour2[1]) ** 2 + (colour1[2] - colour2[2]) ** 2

View File

@ -0,0 +1,316 @@
# cython: infer_types=True
# cython: profile=False
# cython: boundscheck=False
# cython: wraparound=False
cimport cython
import numpy as np
from libc.stdlib cimport malloc, free
cdef float clip(float a, float min_value, float max_value) nogil:
"""Clip a value between min_value and max_value inclusive."""
return min(max(a, min_value), max_value)
cdef inline float[::1] convert_rgb_to_cam16ucs(float[:, ::1] rgb_to_cam16ucs, float r, float g, float b) nogil:
"""Converts floating point (r,g,b) valueto 3-tuple in CAM16UCS colour space, via 24-bit RGB lookup matrix."""
cdef unsigned int rgb_24bit = (<unsigned int>(r*255) << 16) + (<unsigned int>(g*255) << 8) + <unsigned int>(b*255)
return rgb_to_cam16ucs[rgb_24bit]
cdef inline double colour_distance_squared(float[::1] colour1, float[::1] colour2) nogil:
"""Computes Euclidean squared distance between two floating-point colour 3-tuples."""
return (colour1[0] - colour2[0]) ** 2 + (colour1[1] - colour2[1]) ** 2 + (colour1[2] - colour2[2]) ** 2
# 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
int x_origin
int y_origin
# 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
return yb
cdef inline unsigned char shift_pixel_window(
unsigned char last_pixels,
unsigned int next_pixels,
unsigned char shift_right_by,
unsigned char window_width) nogil:
"""Right-shift a sliding window of n pixels to incorporate new pixels.
Args:
last_pixels: n-bit value representing n pixels from left up to current position (MSB = current pixel).
next_pixels: n-bit value representing n pixels to right of current position (LSB = pixel to right)
shift_right_by: how many pixels of next_pixels to shift into the sliding window
window_width: how many pixels to maintain in the sliding window (must be <= 8)
Returns: n-bit value representing shifted pixel window
"""
cdef unsigned char window_mask = 0xff >> (8 - window_width)
cdef unsigned int shifted_next_pixels
if window_width > shift_right_by:
shifted_next_pixels = next_pixels << (window_width - shift_right_by)
else:
shifted_next_pixels = next_pixels >> (shift_right_by - window_width)
return ((last_pixels >> shift_right_by) | shifted_next_pixels) & window_mask
# 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)
#
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 candidate_pixels, i, j
cdef float[3] quant_error
cdef int best
cdef float best_error = 2**31-1
cdef float total_error
cdef unsigned char next_pixels
cdef int phase
cdef float[::1] lah_cam16ucs
# 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 = <float *> 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 candidate_pixels in range(1 << lookahead):
# Working copy of input pixels
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
# 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=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
# 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[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 = candidate_pixels
free(lah_image_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
for i in range(xl, xr):
error_fraction = dither.pattern[i - x + dither.x_origin]
for j in range(3):
image[i * image_shape1 + j] = clip(image[i * image_shape1 + j] + error_fraction * quant_error[j], 0, 1)
# 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
#
cdef void apply(Dither* dither, int x_res, int y_res, int x, int y, float[:,:,::1] image, float[] quant_error) nogil:
cdef int i, j, k
cdef int yt = dither_bounds_yt(dither, y)
cdef int yb = dither_bounds_yb(dither, y_res, y)
cdef int xl = dither_bounds_xl(dither, x)
cdef int xr = dither_bounds_xr(dither, x_res, x)
cdef float error_fraction
for i in range(yt, yb):
for j in range(xl, xr):
error_fraction = dither.pattern[(i - y) * dither.x_shape + j - x + dither.x_origin]
for k in range(3):
image[i,j,k] = clip(image[i,j,k] + error_fraction * quant_error[k], 0, 1)
cdef image_nbit_to_bitmap(
(unsigned char)[:, ::1] image_nbit, unsigned int x_res, unsigned int y_res, unsigned char palette_depth):
cdef unsigned int x, y
bitmap = np.zeros((y_res, x_res), dtype=bool)
for y in range(y_res):
for x in range(x_res):
# MSB of each array element is the pixel state at (x, y)
bitmap[y, x] = image_nbit[y, x] >> (palette_depth - 1)
return bitmap
# 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
#
def dither_image(
screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsigned char verbose, float[:,::1] rgb_to_cam16ucs):
cdef int y, x
cdef unsigned char i, j, pixels_nbit, phase
# cdef float[3] input_pixel_rgb
cdef float[3] quant_error
cdef unsigned char output_pixel_nbit
cdef unsigned char best_next_pixels
cdef float[3] output_pixel_rgb
# Hoist some python attribute accesses into C variables for efficient access during the main loop
cdef int yres = screen.Y_RES
cdef int xres = screen.X_RES
# TODO: convert this instead of storing on palette?
cdef float[:, :, ::1] palette_cam16 = np.zeros((len(screen.palette.CAM16UCS), 4, 3), dtype=np.float32)
for pixels_nbit, phase in screen.palette.CAM16UCS.keys():
for i in range(3):
palette_cam16[pixels_nbit, phase, i] = screen.palette.CAM16UCS[pixels_nbit, phase][i]
cdef float[:, :, ::1] palette_rgb = np.zeros((len(screen.palette.RGB), 4, 3), dtype=np.float32)
for pixels_nbit, phase in screen.palette.RGB.keys():
for i in range(3):
palette_rgb[pixels_nbit, phase, i] = screen.palette.RGB[pixels_nbit, phase][i] / 255
cdef Dither cdither
cdither.y_shape = dither.PATTERN.shape[0]
cdither.x_shape = dither.PATTERN.shape[1]
cdither.y_origin = dither.ORIGIN[0]
cdither.x_origin = dither.ORIGIN[1]
# TODO: should be just as efficient to use a memoryview?
cdither.pattern = <float *> malloc(cdither.x_shape * cdither.y_shape * sizeof(float))
for i in range(cdither.y_shape):
for j in range(cdither.x_shape):
cdither.pattern[i * cdither.x_shape + j] = dither.PATTERN[i, j]
cdef unsigned char palette_depth = screen.palette.PALETTE_DEPTH
# The nbit image representation contains the trailing n dot values as an n-bit value with MSB representing the
# current pixel. This choice (cf LSB) is slightly awkward but matches the DHGR behaviour that bit positions in
# screen memory map LSB to MSB from L to R. The value of n is chosen by the palette depth, i.e. how many trailing
# dot positions are used to determine the colour of a given pixel.
cdef (unsigned char)[:, ::1] image_nbit = np.empty((image_rgb.shape[0], image_rgb.shape[1]), dtype=np.uint8)
for y in range(yres):
if verbose:
print("%d/%d" % (y, yres))
output_pixel_nbit = 0
for x in range(xres):
# 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):
output_pixel_rgb[i] = palette_rgb[output_pixel_nbit, x % 4, i]
quant_error[i] = image_rgb[y,x,i] - output_pixel_rgb[i]
apply(&cdither, xres, yres, x, y, image_rgb, quant_error)
# Update image with our chosen image pixel
image_nbit[y, x] = output_pixel_nbit
for i in range(3):
image_rgb[y, x, i] = output_pixel_rgb[i]
free(cdither.pattern)
return image_nbit_to_bitmap(image_nbit, xres, yres, palette_depth)

View File

@ -0,0 +1,90 @@
"""Error diffusion dither patterns."""
import numpy as np
class DitherPattern:
PATTERN = None
ORIGIN = None
class NoDither(DitherPattern):
"""No dithering."""
PATTERN = np.array(((0, 0), (0, 0)),
dtype=np.float32).reshape(2, 2) / np.float(16)
ORIGIN = (0, 1)
class FloydSteinbergDither(DitherPattern):
"""Floyd-Steinberg dither."""
# 0 * 7
# 3 5 1
PATTERN = np.array(((0, 0, 7), (3, 5, 1)),
dtype=np.float32).reshape(2, 3) / np.float(16)
ORIGIN = (0, 1)
class FloydSteinbergDither2(DitherPattern):
"""Floyd-Steinberg dither."""
# 0 * 7
# 3 5 1
PATTERN = np.array(
((0, 0, 0, 0, 0, 7),
(3, 5, 1, 0, 0, 0)),
dtype=np.float32).reshape(2, 6) / np.float(16)
ORIGIN = (0, 2)
class BuckelsDither(DitherPattern):
"""Default dither from bmp2dhr."""
# 0 * 2 1
# 1 2 1 0
# 0 1 0 0
PATTERN = np.array(((0, 0, 2, 1), (1, 2, 1, 0), (0, 1, 0, 0)),
dtype=np.float32).reshape(3, 4) / np.float32(8)
ORIGIN = (0, 1)
class JarvisDither(DitherPattern):
"""Jarvis-Judice-Ninke dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array(((0, 0, 0, 7, 5), (3, 5, 7, 5, 3), (1, 3, 5, 3, 1)),
dtype=np.float32).reshape(3, 5) / np.float32(48)
ORIGIN = (0, 2)
class JarvisModifiedDither(DitherPattern):
"""Jarvis dithering, modified to diffuse errors to 4 forward x positions.
This works well for double hi-res dithering, since the "best" colour
match to a given pixel may only be accessible up to 4 x-positions further
on. Standard Jarvis dithering only propagates errors for 2 x-positions
in the forward direction, which means that errors may have diffused away
before we get to the pixel that can best take advantage of it.
"""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 15, 11, 7, 3),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
PATTERNS = {
'floyd': FloydSteinbergDither,
'floyd2': FloydSteinbergDither2,
'floyd-steinberg': FloydSteinbergDither,
'buckels': BuckelsDither,
'jarvis': JarvisDither,
'jarvis-mod': JarvisModifiedDither,
'none': NoDither
}
DEFAULT_PATTERN = 'floyd'

View File

@ -0,0 +1,47 @@
"""Image transformation functions."""
import numpy as np
from PIL import Image
def srgb_to_linear_array(a: np.ndarray, gamma=2.4) -> np.ndarray:
return np.where(a <= 0.04045, a / 12.92, ((a + 0.055) / 1.055) ** gamma)
def linear_to_srgb_array(a: np.ndarray, gamma=2.4) -> np.ndarray:
return np.where(a <= 0.0031308, a * 12.92, 1.055 * a ** (1.0 / gamma) -
0.055)
def srgb_to_linear(im: np.ndarray, gamma=2.4) -> np.ndarray:
rgb_linear = srgb_to_linear_array(im / 255.0, gamma=gamma)
return (np.clip(rgb_linear, 0.0, 1.0) * 255).astype(np.float32)
def linear_to_srgb(im: np.ndarray, gamma=2.4) -> np.ndarray:
srgb = linear_to_srgb_array(im / 255.0, gamma=gamma)
return (np.clip(srgb, 0.0, 1.0) * 255).astype(np.float32)
def open(filename: str) -> np.ndarray:
im = Image.open(filename)
# TODO: convert to sRGB colour profile explicitly, in case it has some other
# profile already.
if im.mode != "RGB":
im = im.convert("RGB")
return im
def resize(
image: Image, x_res, y_res, gamma: float = 2.4,
srgb_output: bool = False) -> Image:
# Convert to linear RGB before rescaling so that colour interpolation is
# in linear space
linear = srgb_to_linear(np.asarray(image), gamma=gamma).astype(np.uint8)
res = Image.fromarray(linear).resize((x_res, y_res), Image.LANCZOS)
if srgb_output:
return Image.fromarray(
linear_to_srgb(np.array(res, dtype=np.float32), gamma=gamma).astype(
np.uint8))
else:
return res

View File

@ -0,0 +1,52 @@
"""Precomputes all possible colours available via NTSC emulation."""
import numpy as np
from PIL import Image
import convert.screen
def main():
s = screen.DHGRScreen(palette=None)
colours = {}
unique = set()
print("import numpy as np")
print()
print("# Indexed by (trailing 8-bit dot pattern, x % 4)")
print("SRGB = {")
# For each sequence of 8 pixels, compute the RGB colour of the right-most
# pixel, using NTSC emulation.
# Double Hi-Res has a timing shift that rotates the displayed bits one
# position with respect to NTSC phase.
ntsc_shift = 1
for j in range(ntsc_shift, ntsc_shift + 4):
bitmap = np.zeros((1, 11 + ntsc_shift), dtype=bool)
for bits in range(256):
bits8 = np.empty((8,), dtype=bool)
for i in range(8):
bits8[i] = bits & (1 << i)
bitmap[0, j:j + 8] = bits8
# bitmap_to_ntsc produces 3 output pixels for each DHGR input
ntsc = s.bitmap_to_image_ntsc(bitmap)
last_colour = ntsc[0, 3 * (j + 8) - 1, :]
colours[(bits, j - ntsc_shift)] = last_colour
unique.add(tuple(last_colour))
print(" (%d, %d): np.array((%d, %d, %d))," % (
bits, j - ntsc_shift, 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 * 4, 256 * 16, 3), dtype=np.uint8)
for x, j in colours:
im[128 * j:128 * (j + 1), x * 16: (x + 1) * 16, :] = colours[x, j]
Image.fromarray(im).show()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,184 @@
"""RGB colour palettes to target for Apple II image conversions."""
import colour
import numpy as np
import convert.image as image
import convert.palette_ntsc as palette_ntsc
class Palette:
# How many successive screen pixels are used to compute output pixel
# palette index.
PALETTE_DEPTH = None
# These next three dictionaries are all indexed by a tuple of (n-bit pixel
# value, NTSC phase), where:
# n == PALETTE_DEPTH
# MSB of the pixel value represents the current pixel on/off state
# LSB of the pixel value is the on/off state of the pixel n-1 positions
# to the left of current
# NTSC phase = 0 .. 3 (= x position % 4)
#
# The choice of LSB --> MSB increasing from left to right across the
# screen matches the ordering used by the mapping of double hi-res memory
# to screen pixels.
#
# Dictionary values are the colour of the corresponding pixel in various
# colour spaces.
# Values are pixel colour in sRGB colour space. Palettes are defined in
# this colour space.
SRGB = None
# Values are pixel colour in (linear) RGB colour space. Dithering is
# performed in this colour space.
RGB = {}
# Values are pixel colour in CAM16-UCS colour space. This is used for
# computing perceptual differences between colour values when optimizing
# the image dithering.
CAM16UCS = {}
def __init__(self):
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)
with colour.utilities.suppress_warnings(colour_usage_warnings=True):
self.CAM16UCS[k] = colour.convert(
v / 255, "sRGB", "CAM16UCS").astype(np.float32)
@staticmethod
def _pixel_phase_shifts(phase_3_srgb):
"""Constructs dictionary of 4-bit pixel sequences for each NTSC phase.
Assumes PALETTE_DEPTH == 4
Args:
phase_3_rgb: dict mapping 4-bit pixel sequence to sRGB values,
for NTSC phase 3.
Returns:
dict mapping (shifted 4-bit pixel sequence, phase 0..3) to sRGB
values
"""
srgb_phases = {}
for pixels, srgb in phase_3_srgb.items():
srgb_phases[pixels, 3] = srgb
# Rotate to compute 4-bit pixel sequences that produce the same
# colour for NTSC phases 0..2
for phase in range(0, 3):
lsb = pixels & 1
pixels >>= 1
pixels |= lsb << 3
srgb_phases[pixels, phase] = srgb
return srgb_phases
def bitmap_to_idx(self, pixels: np.array) -> int:
"""Converts a bitmap of pixels into integer representation.
Args:
pixels: 1-D array of booleans, representing a window of pixels from
L to R. Must be of size <= 8
Returns:
8-bit integer representation of pixels, suitable for use as an
index into palette arrays
"""
return np.packbits(
# numpy uses big-endian representation which is the opposite
# order to screen representation (i.e. LSB is the left-most
# screen pixel), so we need to flip the order
np.flip(pixels, axis=0)
)[0] >> (8 - pixels.shape[0])
class ToHgrPalette(Palette):
"""4-bit palette used as default by other DHGR image converters."""
PALETTE_DEPTH = 4
# Default tohgr/bmp2dhr palette
SRGB = Palette._pixel_phase_shifts({
0: np.array((0, 0, 0)), # Black
8: np.array((148, 12, 125)), # Magenta
4: np.array((99, 77, 0)), # Brown
12: np.array((249, 86, 29)), # Orange
2: np.array((51, 111, 0)), # Dark green
10: np.array((126, 126, 126)), # Grey2
6: np.array((67, 200, 0)), # Green
14: np.array((221, 206, 23)), # Yellow
1: np.array((32, 54, 212)), # Dark blue
9: np.array((188, 55, 255)), # Violet
5: np.array((126, 126, 126)), # Grey1
13: np.array((255, 129, 236)), # Pink
3: np.array((7, 168, 225)), # Med blue
11: np.array((158, 172, 255)), # Light blue
7: np.array((93, 248, 133)), # Aqua
15: np.array((255, 255, 255)), # White
})
class OpenEmulatorPalette(Palette):
"""4-bit palette chosen to approximately match OpenEmulator output."""
PALETTE_DEPTH = 4
# OpenEmulator
SRGB = Palette._pixel_phase_shifts({
0: np.array((0, 0, 0)), # Black
8: np.array((203, 0, 121)), # Magenta
4: np.array((99, 103, 0)), # Brown
12: np.array((244, 78, 0)), # Orange
2: np.array((0, 150, 0)), # Dark green
10: np.array((130, 130, 130)), # Grey2
6: np.array((0, 235, 0)), # Green
14: np.array((214, 218, 0)), # Yellow
1: np.array((20, 0, 246)), # Dark blue
9: np.array((230, 0, 244)), # Violet
5: np.array((130, 130, 130)), # Grey1
13: np.array((244, 105, 235)), # Pink
3: np.array((0, 174, 243)), # Med blue
11: np.array((160, 156, 244)), # Light blue
7: np.array((25, 243, 136)), # Aqua
15: np.array((244, 247, 244)), # White
})
class VirtualIIPalette(Palette):
"""4-bit palette exactly matching Virtual II emulator output."""
PALETTE_DEPTH = 4
SRGB = Palette._pixel_phase_shifts({
0: np.array((0, 0, 0)), # Black
8: np.array((231, 36, 66)), # Magenta
4: np.array((154, 104, 0)), # Brown
12: np.array((255, 124, 0)), # Orange
2: np.array((0, 135, 45)), # Dark green
10: np.array((104, 104, 104)), # Grey2
6: np.array((0, 222, 0)), # Green
14: np.array((255, 252, 0)), # Yellow
1: np.array((1, 30, 169)), # Dark blue
9: np.array((230, 73, 228)), # Violet
5: np.array((185, 185, 185)), # Grey1
13: np.array((255, 171, 153)), # Pink
3: np.array((47, 69, 255)), # Med blue
11: np.array((120, 187, 255)), # Light blue
7: np.array((83, 250, 208)), # Aqua
15: np.array((255, 255, 255)), # White
})
class NTSCPalette(Palette):
"""8-bit NTSC palette computed by averaging chroma signal over 8 pixels."""
PALETTE_DEPTH = 8
# Computed using ntsc_colours.py
SRGB = palette_ntsc.SRGB
PALETTES = {
'openemulator': OpenEmulatorPalette,
'virtualii': VirtualIIPalette,
'tohgr': ToHgrPalette,
'ntsc': NTSCPalette
}
DEFAULT_PALETTE = 'ntsc'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,68 @@
"""Precompute CAM16-UCS colour tuples for all 2^24 RGB tuples.
This 192MB data file is used to convert from RGB to CAM16-UCS colour space
for purposes of computing (approximate) perceptual difference between pairs of
colours when optimizing the image conversion (since this perceptual
difference corresponds to the Euclidean distance in this colour space)
"""
import colour
import numpy as np
def srgb_to_linear_rgb_array(a: np.ndarray, gamma=2.4) -> np.ndarray:
return np.where(a <= 0.04045, a / 12.92, ((a + 0.055) / 1.055) ** gamma)
def main():
print("Precomputing conversion matrix from 24-bit RGB to CAM16UCS colour "
"space")
# Compute matrix of all 24-bit RGB values, normalized to 0..1 range
bits24 = np.arange(2 ** 24, dtype=np.uint32).reshape(-1, 1)
all_rgb24 = np.concatenate(
[bits24 >> 16 & 0xff, bits24 >> 8 & 0xff, bits24 & 0xff],
axis=1).astype(np.float32) / 255
del bits24
with colour.utilities.suppress_warnings(colour_usage_warnings=True):
# Compute matrix of corresponding CAM16UCS colour values, indexed
# by 24-bit RGB value
rgb24_to_cam16ucs = colour.convert(all_rgb24, "RGB", "CAM16UCS").astype(
np.float32)
del all_rgb24
np.save("data/rgb24_to_cam16ucs.npy", rgb24_to_cam16ucs)
del rgb24_to_cam16ucs
# print("Precomputing conversion matrix from 12-bit //gs RGB to CAM16UCS "
# "colour space")
# # Compute matrix of all 12-bit RGB values, normalized to 0..1 range
# bits12 = np.arange(2 ** 12, dtype=np.uint32).reshape(-1, 1)
# r = bits12 >> 8
# g = (bits12 >> 4) & 0xf
# b = bits12 & 0xf
# all_rgb12 = np.concatenate(
# [(r << 4) | r, (g << 4) | g, (b << 4) | b], axis=1).astype(
# np.float32) / 255
# del bits12, r, g, b
#
# # //gs RGB values use gamma-corrected Rec.601 RGB colour space. We need to
# # convert to Rec.709 RGB as preparation for converting to CAM16UCS. We
# # do this via the YCbCr intermediate color model.
# rgb12_iigs = np.clip(srgb_to_linear_rgb_array(
# np.clip(colour.YCbCr_to_RGB(
# colour.RGB_to_YCbCr(
# all_rgb12, K=colour.WEIGHTS_YCBCR[
# 'ITU-R BT.601']),
# K=colour.WEIGHTS_YCBCR['ITU-R BT.709']), 0, 1)), 0, 1)
# with colour.utilities.suppress_warnings(colour_usage_warnings=True):
# # Compute matrix of corresponding CAM16UCS colour values, indexed
# # by 12-bit //gs RGB value
# rgb12_iigs_to_cam16ucs = colour.convert(
# rgb12_iigs, "RGB", "CAM16UCS").astype(np.float32)
# del rgb12_iigs
# np.save("data/rgb12_iigs_to_cam16ucs.npy", rgb12_iigs_to_cam16ucs)
# del rgb12_iigs_to_cam16ucs
if __name__ == "__main__":
main()

View File

@ -0,0 +1,200 @@
"""Representation of Apple II screen memory."""
import math
import numpy as np
import convert.palette as palette_py
class SHR320Screen:
X_RES = 320
Y_RES = 200
def __init__(self):
self.palettes = {k: np.zeros((16, 3), dtype=np.uint8) for k in
range(16)}
# Really 4-bit values, indexing into palette
self.pixels = np.array((self.Y_RES, self.X_RES), dtype=np.uint8)
# Choice of palette per scan-line
self.line_palette = np.zeros(self.Y_RES, dtype=np.uint8)
self.memory = None
def set_palette(self, idx: int, palette: np.array):
if idx < 0 or idx > 15:
raise ValueError("Palette index %s must be in range 0 .. 15" % idx)
if palette.shape != (16, 3):
raise ValueError("Palette size %s != (16, 3)" % palette.shape)
# XXX check element range
if palette.dtype != np.uint8:
raise ValueError("Palette must be of type np.uint8")
# print(palette)
self.palettes[idx] = np.array(palette)
def set_pixels(self, pixels):
self.pixels = np.array(pixels)
def pack(self):
dump = np.zeros(32768, dtype=np.uint8)
for y in range(self.Y_RES):
pixel_pair = 0
for x in range(self.X_RES):
if x % 2 == 0:
pixel_pair |= (self.pixels[y, x] << 4)
else:
pixel_pair |= self.pixels[y, x]
# print(pixel_pair)
dump[y * 160 + (x - 1) // 2] = pixel_pair
pixel_pair = 0
scan_control_offset = 320 * 200 // 2
for y in range(self.Y_RES):
dump[scan_control_offset + y] = self.line_palette[y]
palette_offset = scan_control_offset + 256
for palette_idx, palette in self.palettes.items():
for rgb_idx, rgb in enumerate(palette):
r, g, b = rgb
assert r <= 15 and g <= 15 and b <= 15
# print(r, g, b)
rgb_low = (g << 4) | b
rgb_hi = r
# print(hex(rgb_hi), hex(rgb_low))
palette_idx_offset = palette_offset + (32 * palette_idx)
dump[palette_idx_offset + (2 * rgb_idx)] = rgb_low
dump[palette_idx_offset + (2 * rgb_idx + 1)] = rgb_hi
self.memory = dump
class DHGRScreen:
X_RES = 560
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)
self.palette = palette
@staticmethod
def y_to_base_addr(y: int) -> int:
"""Maps y coordinate to screen memory base address."""
a = y // 64
d = y - 64 * a
b = d // 8
c = d - 8 * b
return 1024 * c + 128 * b + 40 * a
def pack(self, bitmap: np.ndarray):
"""Packs an image into memory format (8k AUX + 8K MAIN)."""
# The DHGR display encodes 7 pixels across interleaved 4-byte sequences
# of AUX and MAIN memory, as follows:
# 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 // 14), dtype=np.uint8)
aux_col = np.zeros(
(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):
column |= (bitmap[:, 7 * byte_offset + bit].astype(
np.uint8) << bit)
if byte_offset % 2 == 0:
aux_col[:, byte_offset // 2] = column
else:
main_col[:, (byte_offset - 1) // 2] = column
for y in range(self.Y_RES):
addr = self.y_to_base_addr(y)
self.aux[addr:addr + 40] = aux_col[y, :]
self.main[addr:addr + 40] = main_col[y, :]
return
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 an n-bit sliding
window and x % 4, which give the index into our RGB palette.
"""
image_rgb = np.empty((self.Y_RES, self.X_RES, 3), dtype=np.uint8)
for y in range(self.Y_RES):
bitmap_window = [False] * self.palette.PALETTE_DEPTH
for x in range(self.X_RES):
# Maintain a sliding window of pixels of width PALETTE_DEPTH
bitmap_window = bitmap_window[1:] + [bitmap[y, x]]
image_rgb[y, x, :] = self.palette.RGB[
self.palette.bitmap_to_idx(
np.array(bitmap_window, dtype=bool)), x % 4]
return image_rgb
@staticmethod
def _sin(pos, phase0=0):
x = pos % 12 + phase0
return np.sin(x * 2 * np.pi / 12)
@staticmethod
def _cos(pos, phase0=0):
x = pos % 12 + phase0
return np.cos(x * 2 * np.pi / 12)
def _read(self, line, pos):
if pos < 0:
return 0
return 1 if line[pos] else 0
def bitmap_to_image_ntsc(self, bitmap: np.ndarray) -> np.ndarray:
y_width = 12
u_width = 24
v_width = 24
contrast = 1
# TODO: This is necessary to match OpenEmulator. I think it is because
# they introduce an extra (unexplained) factor of 2 when applying the
# Chebyshev/Lanczos filtering to the u and v components.
saturation = 2
# TODO: this phase shift is necessary to match OpenEmulator. I'm not
# sure where it comes from - e.g. it doesn't match the phaseInfo
# calculation for the signal phase at the start of the visible region.
hue = 0.2 * (2 * np.pi)
# 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.139883), (1, -0.394642, -.5806227), (1, 2.032062, 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

View File

@ -0,0 +1,13 @@
from setuptools import setup
from Cython.Build import cythonize
import Cython.Compiler.Options
Cython.Compiler.Options.annotate = True
setup(
ext_modules=cythonize(
["dither_dhr.pyx"],
annotate=True,
compiler_directives={'language_level': "3"}
)
)