ii-pix/screen.py
kris ad9515dcf2 Implement NTSC emulation, using an 8 pixel window for chroma signal.
Use this to precompute a new ntsc palette with 256 entries (though
only 84 unique colours) that are available by appropriate pixel
sequences.  Unfortunately the precomputed distance matrix for this
palette is 4GB!

Optimize the precomputation to be less memory hungry, while also
making efficient use of the mmapped output file.

Add support for dithering images using this 8-bit palette depth,
i.e. to optimize for NTSC rendering.  This often gives better image
quality since more colours are available, especially when modulating
areas of similar colour.

Fix 140 pixel dithering and render the output including NTSC fringing
instead of the unrealistic 140px output that doesn't include it.

Add support for rendering output image using any target palette, which
is useful e.g. for comparing how an 8-pixel NTSC rendered image will
be displayed on an emulator using 4-pixel ntsc emulation (there is
usually some colour bias, because the 8 pixel chroma blending tends to
average away colours).

Switch the output binary format to write AUX memory first, which
matches the image format of other utilities.
2021-02-14 23:34:25 +00:00

268 lines
10 KiB
Python

"""Representation of Apple II screen memory."""
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
Y_RES = None
X_PIXEL_WIDTH = None
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 _image_to_bitmap(self, image: np.ndarray) -> np.ndarray:
"""Converts 4-bit image to 2-bit image bitmap.
Each 4-bit colour value maps to a sliding window of 4 successive pixels,
via x%4.
"""
raise NotImplementedError
def pack(self, image: np.ndarray):
"""Packs an image into memory format (8k AUX + 8K MAIN)."""
bitmap = self._image_to_bitmap(image)
# 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 * self.X_PIXEL_WIDTH // 14), dtype=np.uint8)
aux_col = np.zeros(
(self.Y_RES, self.X_RES * self.X_PIXEL_WIDTH // 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 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 4-bit sliding
window indexed by x % 4, which gives the index into our 16-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]
for x in range(560):
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_4bit, x: int):
"""Returns available colours for given x pos and 4-bit colour of x-1"""
raise NotImplementedError
@staticmethod
def _sin(pos, phase0=4):
x = pos % 12 + phase0
return 8 * np.sin(x * 2 * np.pi / 12)
@staticmethod
def _cos(pos, phase0=4):
x = pos % 12 + phase0
return 8 * np.cos(x * 2 * np.pi / 12)
def _read(self, line, pos):
if pos < 0:
return 0
# Sather says black level is 0.36V and white level 1.1V, but this
# doesn't seem to be right (they correspond to values -29 and +33)
# which means that 0101 grey has Y value ~0, i.e. is black. These are
# only mentioned as labels on figure 8.2 though.
#
# _The Apple II Circuit description_ by W. Gayler gives black=0.5V
# and white=2.0V which is much more plausible.
#
# Conversion is given by floor((voltage-0.518)*1000/12)-15
return 108 if line[pos] else 0 # -16
def bitmap_to_ntsc(self, bitmap: np.ndarray) -> np.ndarray:
"""
See http://forums.nesdev.com/viewtopic.php?p=172329#p172329
"""
y_width = 12
i_width = 24
q_width = 24
contrast = 167941
saturation = 144044
yr = contrast / y_width
ir = contrast * 1.994681e-6 * saturation / i_width
qr = contrast * 9.915742e-7 * saturation / q_width
yg = contrast / y_width
ig = contrast * 9.151351e-8 * saturation / i_width
qg = contrast * -6.334805e-7 * saturation / q_width
yb = contrast / y_width
ib = contrast * -1.012984e-6 * saturation / i_width
qb = contrast * 1.667217e-6 * saturation / q_width
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(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))
qsum += self._read(line, x) * self._sin(x) - self._read(
line, x - q_width) * self._sin((x - q_width))
r = min(255, max(0, ysum * yr + isum * ir + qsum * qr) /
65536)
g = min(255,
max(0, (ysum * yg + isum * ig + qsum * qg) / 65536))
b = min(255,
max(0, (ysum * yb + isum * ib + qsum * qb) / 65536))
out_rgb[y, x, :] = (r, g, b)
return out_rgb
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_4bit: 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_4bit[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_4bit, 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))
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_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[phase]
return bitmap
def pixel_palette_options(self, last_pixel_4bit, x: int):
# The two available colours for position x are given by the 4-bit
# value of position x-1, and the 4-bit value produced by toggling the
# value of the x % 4 bit (the current value of NTSC phase)
last_dots = self.palette.DOTS[last_pixel_4bit]
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_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([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))