diff --git a/screen.py b/screen.py index 74f35d2..4254516 100644 --- a/screen.py +++ b/screen.py @@ -125,41 +125,124 @@ class DHGRScreen: self.main[addr:addr + 40] = main_col[y, :] return -class HGRScreen: + +class NTSCScreen: + NTSC_PHASE_SHIFT = None + + def _sin(self, pos): + x = pos % 12 + self.NTSC_PHASE_SHIFT * 3 + return np.sin(x * 2 * np.pi / 12) + + def _cos(self, pos): + x = pos % 12 + self.NTSC_PHASE_SHIFT * 3 + 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.float32) + # 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 + + 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( + # Mapping from bit pattern to colour is rotated by + # NTSC phase shift + np.roll( + np.array(bitmap_window, dtype=bool), + self.NTSC_PHASE_SHIFT + ) + ), x % 4] + return image_rgb + +class DHGRNTSCScreen(DHGRScreen, NTSCScreen): + def __init__(self, palette: palette_py.Palette): + self.palette = palette + super(DHGRNTSCScreen, self).__init__() + + NTSC_PHASE_SHIFT = 0 + + +class HGRScreen(NTSCScreen): X_RES = 560 Y_RES = 192 MODE = Mode.HI_RES + NTSC_PHASE_SHIFT = 3 + def __init__(self, palette: palette_py.Palette): self.main = np.zeros(8192, dtype=np.uint8) self.palette = palette super(HGRScreen, self).__init__() - @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 - - @staticmethod - def compute_fat_pixels(screen_byte, last_pixels): - result = 0 - for i in range(7): - bit = (screen_byte >> i) & 0b1 - fat_bit = bit << 1 | bit - result |= fat_bit << (2 * i) - if screen_byte & 0x80: - # Palette bit shifts to the right - result <<= 1 - result |= (last_pixels >> 7) - - return result - def pack_bytes(self, linear_bytemap: np.ndarray): """Packs an image into memory format (8K main).""" @@ -167,185 +250,3 @@ class HGRScreen: addr = self.y_to_base_addr(y) self.main[addr:addr + 40] = linear_bytemap[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=9): - x = pos % 12 + phase0 - return np.sin(x * 2 * np.pi / 12) - - @staticmethod - def _cos(pos, phase0=9): - 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.float32) - # 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 - - -class DHGRNTSCScreen(DHGRScreen): - def __init__(self, palette: palette_py.Palette): - self.palette = palette - super(DHGRNTSCScreen, self).__init__() - - 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.float32) - # 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