Parametrize the RGB palette to encode with, and support both NTSC and

IIGS palettes.

Move the palette diff_matrix generation into make_data_tables.py since
that is the only place it is used.

Demand-load the edit distance matrices when transcoding.
This commit is contained in:
kris 2019-06-15 21:02:00 +01:00
parent 696eb61bf4
commit edefe649f4
11 changed files with 236 additions and 156 deletions

Binary file not shown.

View File

@ -11,6 +11,7 @@ import skvideo.io
from PIL import Image
import screen
from palette import Palette
from video_mode import VideoMode
@ -24,10 +25,11 @@ class FrameGrabber:
class FileFrameGrabber(FrameGrabber):
def __init__(self, filename, mode: VideoMode):
def __init__(self, filename, mode: VideoMode, palette: Palette):
super(FileFrameGrabber, self).__init__(mode)
self.filename = filename # type: str
self.palette = palette # type: Palette
self._reader = skvideo.io.FFmpegReader(filename)
# Compute frame rate from input video
@ -43,8 +45,12 @@ class FileFrameGrabber(FrameGrabber):
@staticmethod
def _output_dir(filename) -> str:
# TODO: should include palette
return ".".join(filename.split(".")[:-1])
def _palette_arg(self) -> str:
return "P%d" % self.palette.value
def frames(self) -> Iterator[screen.MemoryMap]:
"""Encode frame to HGR using bmp2dhr.
@ -69,11 +75,9 @@ class FileFrameGrabber(FrameGrabber):
_frame = _frame.resize((280, 192), resample=Image.LANCZOS)
_frame.save(bmpfile)
# TODO: parametrize palette
subprocess.call([
"/usr/local/bin/bmp2dhr", bmpfile, "hgr",
"P5",
# "P0", # Kegs32 RGB Color palette(for //gs playback)
self._palette_arg(),
"D9" # Buckels dither
])
@ -96,11 +100,9 @@ class FileFrameGrabber(FrameGrabber):
_frame = _frame.resize((280, 192), resample=Image.LANCZOS)
_frame.save(bmpfile)
# TODO: parametrize palette
subprocess.call([
"/usr/local/bin/bmp2dhr", bmpfile, "dhgr", # "v",
"P5", # "P0", # Kegs32 RGB Color palette (for //gs
# playback)
self._palette_arg(),
"A", # Output separate .BIN and .AUX files
"D9" # Buckels dither
])

View File

@ -3,6 +3,7 @@
import argparse
import movie
import palette
import video_mode
parser = argparse.ArgumentParser(
@ -28,6 +29,10 @@ parser.add_argument(
'--video_mode', type=str, choices=video_mode.VideoMode.__members__.keys(),
help='Video display mode to encode for (HGR/DHGR)'
)
parser.add_argument(
'--palette', type=str, choices=palette.Palette.__members__.keys(),
help='Video palette to encode for (default=NTSC)'
)
def main(args):
@ -37,9 +42,12 @@ def main(args):
every_n_video_frames=args.every_n_video_frames,
audio_normalization=args.audio_normalization,
max_bytes_out=1024. * 1024 * args.max_output_mb,
video_mode=video_mode.VideoMode[args.video_mode]
video_mode=video_mode.VideoMode[args.video_mode],
palette=palette.Palette[args.palette],
)
print("Palette %s" % args.palette)
print("Input frame rate = %f" % m.frame_grabber.input_frame_rate)
if args.output:

View File

@ -1,11 +1,16 @@
import bz2
import functools
import pickle
from typing import Iterable
from typing import Dict, List, Iterable, Type
import colormath.color_conversions
import colormath.color_diff
import colormath.color_objects
import numpy as np
import weighted_levenshtein
import colours
import palette
# The DHGR display encodes 7 pixels across interleaved 4-byte sequences
@ -65,7 +70,6 @@ import palette
# contiguously into an array whose index is the (source, target) pair and
# the value is the edit distance.
PIXEL_CHARS = "0123456789ABCDEF"
@ -74,14 +78,14 @@ def pixel_char(i: int) -> str:
@functools.lru_cache(None)
def pixel_string(pixels: Iterable[palette.DHGRColours]) -> str:
def pixel_string(pixels: Iterable[colours.DHGRColours]) -> str:
return "".join(pixel_char(p.value) for p in pixels)
@functools.lru_cache(None)
def pixels_influenced_by_byte_index(
pixels: Iterable[palette.DHGRColours],
idx: int) -> Iterable[palette.DHGRColours]:
pixels: str,
idx: int) -> str:
"""Return subset of pixels that are influenced by given byte index (0..4)"""
start, end = {
0: (0, 1),
@ -93,52 +97,6 @@ def pixels_influenced_by_byte_index(
return pixels[start:end + 1]
# Don't even consider insertions and deletions into the string, they don't
# make sense for comparing pixel strings
insert_costs = np.ones(128, dtype=np.float64) * 100000
delete_costs = np.ones(128, dtype=np.float64) * 100000
# Smallest substitution value is ~20 from palette.diff_matrix, i.e.
# we always prefer to transpose 2 pixels rather than substituting colours.
transpose_costs = np.ones((128, 128), dtype=np.float64) * 10
substitute_costs = np.zeros((128, 128), dtype=np.float64)
# Substitution costs to use when evaluating other potential offsets at which
# to store a content byte. We penalize more harshly for introducing
# errors that alter pixel colours, since these tend to be very
# noticeable as visual noise.
error_substitute_costs = np.zeros((128, 128), dtype=np.float64)
def make_substitute_costs():
# Penalty for changing colour
for i, c in enumerate(PIXEL_CHARS):
for j, d in enumerate(PIXEL_CHARS):
cost = palette.diff_matrix[i, j]
substitute_costs[(ord(c), ord(d))] = cost # / 20
substitute_costs[(ord(d), ord(c))] = cost # / 20
error_substitute_costs[(ord(c), ord(d))] = 5 * cost # / 4
error_substitute_costs[(ord(d), ord(c))] = 5 * cost # / 4
make_substitute_costs()
@functools.lru_cache(None)
def edit_distance(a, b, error: bool):
res = weighted_levenshtein.dam_lev(
a, b,
insert_costs=insert_costs,
delete_costs=delete_costs,
substitute_costs=error_substitute_costs if error else substitute_costs,
)
assert res == 0 or (1 <= res < 2 ** 16), res
return res
@functools.lru_cache(None)
def int28_to_pixels(int28):
return tuple(
@ -170,7 +128,77 @@ def map_int8_to_mask32_3(int8):
return int8 << 20
def make_edit_distance():
class EditDistanceParams:
# Don't even consider insertions and deletions into the string, they don't
# make sense for comparing pixel strings
insert_costs = np.ones(128, dtype=np.float64) * 100000
delete_costs = np.ones(128, dtype=np.float64) * 100000
# Smallest substitution value is ~20 from palette.diff_matrices, i.e.
# we always prefer to transpose 2 pixels rather than substituting colours.
transpose_costs = np.ones((128, 128), dtype=np.float64) * 10
substitute_costs = np.zeros((128, 128), dtype=np.float64)
# Substitution costs to use when evaluating other potential offsets at which
# to store a content byte. We penalize more harshly for introducing
# errors that alter pixel colours, since these tend to be very
# noticeable as visual noise.
error_substitute_costs = np.zeros((128, 128), dtype=np.float64)
def compute_diff_matrix(pal: Type[palette.BasePalette]):
# Compute matrix of CIE2000 delta values for this pal, representing
# perceptual distance between colours.
dm = np.ndarray(shape=(16, 16), dtype=np.int)
for colour1, a in pal.RGB.items():
alab = colormath.color_conversions.convert_color(
a, colormath.color_objects.LabColor)
for colour2, b in pal.RGB.items():
blab = colormath.color_conversions.convert_color(
b, colormath.color_objects.LabColor)
dm[colour1.value, colour2.value] = int(
colormath.color_diff.delta_e_cie2000(alab, blab))
return dm
def make_substitute_costs(pal: Type[palette.BasePalette]):
edp = EditDistanceParams()
diff_matrix = compute_diff_matrix(pal)
# Penalty for changing colour
for i, c in enumerate(PIXEL_CHARS):
for j, d in enumerate(PIXEL_CHARS):
cost = diff_matrix[i, j]
edp.substitute_costs[(ord(c), ord(d))] = cost # / 20
edp.substitute_costs[(ord(d), ord(c))] = cost # / 20
edp.error_substitute_costs[(ord(c), ord(d))] = 5 * cost # / 4
edp.error_substitute_costs[(ord(d), ord(c))] = 5 * cost # / 4
return edp
@functools.lru_cache(None)
def edit_distance(
edp: EditDistanceParams,
a: str,
b: str,
error: bool) -> np.float64:
res = weighted_levenshtein.dam_lev(
a, b,
insert_costs=edp.insert_costs,
delete_costs=edp.delete_costs,
substitute_costs=(
edp.error_substitute_costs if error else edp.substitute_costs),
)
assert res == 0 or (1 <= res < 2 ** 16), res
return res
def make_edit_distance(edp: EditDistanceParams):
edit = [
np.zeros(shape=(2 ** 16), dtype=np.int16),
np.zeros(shape=(2 ** 24), dtype=np.int16),
@ -191,8 +219,8 @@ def make_edit_distance():
second_pixels = pixels_influenced_by_byte_index(
pixel_string(int28_to_pixels(second)), 0)
edit[0][pair] = edit_distance(first_pixels, second_pixels,
error=False)
edit[0][pair] = edit_distance(
edp, first_pixels, second_pixels, error=False)
first = map_int8_to_mask32_3(i)
second = map_int8_to_mask32_3(j)
@ -202,8 +230,8 @@ def make_edit_distance():
second_pixels = pixels_influenced_by_byte_index(
pixel_string(int28_to_pixels(second)), 3)
edit[3][pair] = edit_distance(first_pixels, second_pixels,
error=False)
edit[3][pair] = edit_distance(
edp, first_pixels, second_pixels, error=False)
for i in range(2 ** 12):
print(i)
@ -218,8 +246,8 @@ def make_edit_distance():
second_pixels = pixels_influenced_by_byte_index(
pixel_string(int28_to_pixels(second)), 1)
edit[1][pair] = edit_distance(first_pixels, second_pixels,
error=False)
edit[1][pair] = edit_distance(
edp, first_pixels, second_pixels, error=False)
first = map_int12_to_mask32_2(i)
second = map_int12_to_mask32_2(j)
@ -229,22 +257,23 @@ def make_edit_distance():
second_pixels = pixels_influenced_by_byte_index(
pixel_string(int28_to_pixels(second)), 2)
edit[2][pair] = edit_distance(first_pixels, second_pixels,
error=False)
edit[2][pair] = edit_distance(
edp, first_pixels, second_pixels, error=False)
return edit
def main():
edit = make_edit_distance()
for p in palette.PALETTES.values():
print("Processing palette %s" % p)
edp = make_substitute_costs(p)
edit = make_edit_distance(edp)
# TODO: error distance matrices
with bz2.open(
"transcoder/edit_distance.pickle.bz2", "wb",
compresslevel=9) as out:
pickle.dump(
edit, out, protocol=pickle.HIGHEST_PROTOCOL)
data = "transcoder/data/palette_%d_edit_distance.pickle" \
".bz2" % p.ID.value
with bz2.open(data, "wb", compresslevel=9) as out:
pickle.dump(edit, out, protocol=pickle.HIGHEST_PROTOCOL)
if __name__ == "__main__":

View File

@ -10,31 +10,15 @@ class TestMakeDataTables(unittest.TestCase):
self.assertEqual("0FC", make_data_tables.pixel_string(pixels))
def test_pixels_influenced_by_byte_index(self):
pixels = (
DHGRColours.ORANGE,
DHGRColours.GREEN,
DHGRColours.BLACK,
DHGRColours.BLACK,
DHGRColours.BLACK,
DHGRColours.BLACK,
DHGRColours.BLACK,
)
pixels = "CB00000"
self.assertEqual(
(DHGRColours.ORANGE, DHGRColours.GREEN),
"CB",
make_data_tables.pixels_influenced_by_byte_index(pixels, 0)
)
pixels = (
DHGRColours.BLACK,
DHGRColours.BROWN,
DHGRColours.YELLOW,
DHGRColours.GREY1,
DHGRColours.BLACK,
DHGRColours.BLACK,
DHGRColours.BLACK,
)
pixels = "CBA9000"
self.assertEqual(
(DHGRColours.BROWN, DHGRColours.YELLOW, DHGRColours.GREY1),
"BA9",
make_data_tables.pixels_influenced_by_byte_index(pixels, 1)
)

View File

@ -7,6 +7,7 @@ import frame_grabber
import machine
import opcodes
import video
from palette import Palette
from video_mode import VideoMode
@ -17,19 +18,22 @@ class Movie:
audio_normalization: float = None,
max_bytes_out: int = None,
video_mode: VideoMode = VideoMode.HGR,
palette: Palette = Palette.NTSC,
):
self.filename = filename # type: str
self.every_n_video_frames = every_n_video_frames # type: int
self.max_bytes_out = max_bytes_out # type: int
self.video_mode = video_mode # type: VideoMode
self.palette = palette # type: Palette
self.audio = audio.Audio(
filename, normalization=audio_normalization) # type: audio.Audio
self.frame_grabber = frame_grabber.FileFrameGrabber(
filename, mode=video_mode)
filename, mode=video_mode, palette=self.palette)
self.video = video.Video(
self.frame_sequencer, mode=video_mode,
palette=self.palette,
ticks_per_second=self.audio.sample_rate
) # type: video.Video

View File

@ -1,18 +1,37 @@
import enum
from typing import Dict, Type
import colormath.color_objects
import colormath.color_diff
import colormath.color_conversions
import numpy as np
from colours import DHGRColours
# Type annotation
RGB = colormath.color_objects.sRGBColor
def rgb(r, g, b):
return colormath.color_objects.sRGBColor(r, g, b, is_upscaled=True)
return RGB(r, g, b, is_upscaled=True)
class Palette(enum.Enum):
"""BMP2DHR palette numbers"""
UNKNOWN = -1
IIGS = 0
NTSC = 5
class BasePalette:
ID = Palette.UNKNOWN # type: Palette
# Palette RGB map
RGB = {} # type: Dict[DHGRColours: RGB]
class NTSCPalette(BasePalette):
ID = Palette.NTSC
# Palette RGB values taken from BMP2DHGR's default NTSC palette
# TODO: support other palettes as well, e.g. //gs RGB
palette = {
RGB = {
DHGRColours.BLACK: rgb(0, 0, 0),
DHGRColours.MAGENTA: rgb(148, 12, 125),
DHGRColours.BROWN: rgb(99, 77, 0),
@ -32,20 +51,31 @@ palette = {
}
def compute_diff_matrix():
# Compute matrix of CIE2000 delta values for this palette, representing
# perceptual distance between colours.
dm = np.ndarray(shape=(16, 16), dtype=np.int)
class IIGSPalette(BasePalette):
ID = Palette.IIGS
for colour1, a in palette.items():
alab = colormath.color_conversions.convert_color(
a, colormath.color_objects.LabColor)
for colour2, b in palette.items():
blab = colormath.color_conversions.convert_color(
b, colormath.color_objects.LabColor)
dm[colour1.value, colour2.value] = int(
colormath.color_diff.delta_e_cie2000(alab, blab))
return dm
# Palette RGB values taken from BMP2DHGR's KEGS32 palette
RGB = {
DHGRColours.BLACK: rgb(0, 0, 0),
DHGRColours.MAGENTA: rgb(221, 0, 51),
DHGRColours.BROWN: rgb(136, 85, 34),
DHGRColours.ORANGE: rgb(255, 102, 0),
DHGRColours.DARK_GREEN: rgb(0, 119, 0),
DHGRColours.GREY1: rgb(85, 85, 85),
DHGRColours.GREEN: rgb(0, 221, 0),
DHGRColours.YELLOW: rgb(255, 255, 0),
DHGRColours.DARK_BLUE: rgb(0, 0, 153),
DHGRColours.VIOLET: rgb(221, 0, 221),
DHGRColours.GREY2: rgb(170, 170, 170),
DHGRColours.PINK: rgb(255, 153, 136),
DHGRColours.MED_BLUE: rgb(34, 34, 255),
DHGRColours.LIGHT_BLUE: rgb(102, 170, 255),
DHGRColours.AQUA: rgb(0, 255, 153),
DHGRColours.WHITE: rgb(255, 255, 255)
}
diff_matrix = compute_diff_matrix()
PALETTES = {
Palette.IIGS: IIGSPalette,
Palette.NTSC: NTSCPalette
} # type: Dict[Palette, Type[BasePalette]]

View File

@ -3,9 +3,10 @@
import bz2
import functools
import pickle
from typing import Union
from typing import Union, List
import numpy as np
import palette
# Type annotation for cases where we may process either an int or a numpy array.
IntOrArray = Union[int, np.ndarray]
@ -140,10 +141,15 @@ class DHGRBitmap:
# How much to right-shift bits after masking to bring into int8/int12 range
BYTE_SHIFTS = [0, 4, 12, 20]
# Load edit distance matrices for masked, shifted byte 0..3 values
# TODO: should go somewhere else since we don't use it here at all
with bz2.open("transcoder/edit_distance.pickle.bz2", "rb") as ed:
edit_distances = pickle.load(ed)
@staticmethod
@functools.lru_cache(None)
def edit_distances(palette_id: palette.Palette) -> List[np.ndarray]:
"""Load edit distance matrices for masked, shifted byte 0..3 values."""
data = "transcoder/data/palette_%d_edit_distance.pickle.bz2" % (
palette_id.value
)
with bz2.open(data, "rb") as ed:
return pickle.load(ed) # type: List[np.ndarray]
def __init__(self, main_memory: MemoryMap, aux_memory: MemoryMap):
self.main_memory = main_memory

View File

@ -10,6 +10,7 @@ import numpy as np
import opcodes
import screen
from frame_grabber import FrameGrabber
from palette import Palette
from video_mode import VideoMode
@ -21,13 +22,17 @@ class Video:
def __init__(
self,
frame_grabber: FrameGrabber,
mode: VideoMode = VideoMode.HGR
mode: VideoMode = VideoMode.HGR,
palette: Palette = Palette.NTSC,
ticks_per_second: int,
):
self.mode = mode # type: VideoMode
self.frame_grabber = frame_grabber # type: FrameGrabber
self.ticks_per_second = float(ticks_per_second) # type: float
self.ticks_per_frame = (
self.ticks_per_second / self.input_frame_rate) # type: float
self.frame_number = 0 # type: int
self.palette = palette # type: Palette
# Initialize empty screen
self.memory_map = screen.MemoryMap(
@ -213,8 +218,8 @@ class Video:
heapq.heapify(priorities)
return priorities
@staticmethod
def _diff_weights(
self,
source: screen.DHGRBitmap,
target: screen.DHGRBitmap,
is_aux: bool
@ -228,14 +233,16 @@ class Video:
# Concatenate 8-bit source and target into 16-bit values
pair0 = (source_pixels0 << 8) + target_pixels0
dist0 = source.edit_distances[0][pair0].reshape(pair0.shape)
dist0 = source.edit_distances(self.palette)[0][pair0].reshape(
pair0.shape)
# Pixels influenced by byte offset 2
source_pixels2 = source.mask_and_shift_data(source.packed, 2)
target_pixels2 = target.mask_and_shift_data(target.packed, 2)
# Concatenate 12-bit source and target into 24-bit values
pair2 = (source_pixels2 << 12) + target_pixels2
dist2 = source.edit_distances[2][pair2].reshape(pair2.shape)
dist2 = source.edit_distances(self.palette)[2][pair2].reshape(
pair2.shape)
diff[:, 0::2] = dist0
diff[:, 1::2] = dist2
@ -245,13 +252,15 @@ class Video:
source_pixels1 = source.mask_and_shift_data(source.packed, 1)
target_pixels1 = target.mask_and_shift_data(target.packed, 1)
pair1 = (source_pixels1 << 12) + target_pixels1
dist1 = source.edit_distances[1][pair1].reshape(pair1.shape)
dist1 = source.edit_distances(self.palette)[1][pair1].reshape(
pair1.shape)
# Pixels influenced by byte offset 3
source_pixels3 = source.mask_and_shift_data(source.packed, 3)
target_pixels3 = target.mask_and_shift_data(target.packed, 3)
pair3 = (source_pixels3 << 8) + target_pixels3
dist3 = source.edit_distances[3][pair3].reshape(pair3.shape)
dist3 = source.edit_distances(self.palette)[3][pair3].reshape(
pair3.shape)
diff[:, 0::2] = dist1
diff[:, 1::2] = dist3
@ -278,12 +287,12 @@ class Video:
else:
pair = (old_pixels << 12) + new_pixels
p = target_pixelmap.edit_distances[byte_offset][pair]
p = target_pixelmap.edit_distances(self.palette)[byte_offset][pair]
return p
@staticmethod
def _compute_delta(
self,
content: int,
target: screen.DHGRBitmap,
old,
@ -301,7 +310,8 @@ class Video:
# Concatenate 8-bit source and target into 16-bit values
pair0 = (source_pixels0 << 8) + target_pixels0
dist0 = target.edit_distances[0][pair0].reshape(pair0.shape)
dist0 = target.edit_distances(self.palette)[0][pair0].reshape(
pair0.shape)
# Pixels influenced by byte offset 2
source_pixels2 = target.mask_and_shift_data(
@ -309,7 +319,8 @@ class Video:
target_pixels2 = target.mask_and_shift_data(target.packed, 2)
# Concatenate 12-bit source and target into 24-bit values
pair2 = (source_pixels2 << 12) + target_pixels2
dist2 = target.edit_distances[2][pair2].reshape(pair2.shape)
dist2 = target.edit_distances(self.palette)[2][pair2].reshape(
pair2.shape)
diff[:, 0::2] = dist0
diff[:, 1::2] = dist2
@ -320,14 +331,16 @@ class Video:
target.masked_update(1, target.packed, content), 1)
target_pixels1 = target.mask_and_shift_data(target.packed, 1)
pair1 = (source_pixels1 << 12) + target_pixels1
dist1 = target.edit_distances[1][pair1].reshape(pair1.shape)
dist1 = target.edit_distances(self.palette)[1][pair1].reshape(
pair1.shape)
# Pixels influenced by byte offset 3
source_pixels3 = target.mask_and_shift_data(
target.masked_update(3, target.packed, content), 3)
target_pixels3 = target.mask_and_shift_data(target.packed, 3)
pair3 = (source_pixels3 << 8) + target_pixels3
dist3 = target.edit_distances[3][pair3].reshape(pair3.shape)
dist3 = target.edit_distances(self.palette)[3][pair3].reshape(
pair3.shape)
diff[:, 0::2] = dist1
diff[:, 1::2] = dist3

View File

@ -3,6 +3,7 @@
import unittest
import frame_grabber
import palette
import screen
import video
import video_mode
@ -27,11 +28,13 @@ class TestVideo(unittest.TestCase):
diff = v._diff_weights(v.pixelmap, target_pixelmap, is_aux=True)
pal = palette.NTSCPalette
# Expect byte 0 to map to 0b00000000 01111111
expect0 = target_pixelmap.edit_distances[0][0b0000000001111111]
expect0 = target_pixelmap.edit_distances(pal.ID)[0][0b0000000001111111]
# Expect byte 2 to map to 0b000000000000 000101010100
expect2 = target_pixelmap.edit_distances[2][0b000101010100]
expect2 = target_pixelmap.edit_distances(pal.ID)[2][0b000101010100]
self.assertEqual(expect0, diff[0, 0])
self.assertEqual(expect2, diff[0, 1])
@ -61,10 +64,11 @@ class TestVideo(unittest.TestCase):
diff = v._diff_weights(v.pixelmap, target_pixelmap, is_aux=True)
# Expect byte 0 to map to 0b01111111 01101101
expect0 = target_pixelmap.edit_distances[0][0b0111111101101101]
expect0 = target_pixelmap.edit_distances(pal.ID)[0][0b0111111101101101]
# Expect byte 2 to map to 0b000101010100 000011011000
expect2 = target_pixelmap.edit_distances[2][0b0000101010100000011011000]
expect2 = target_pixelmap.edit_distances(pal.ID)[2][
0b0000101010100000011011000]
self.assertEqual(expect0, diff[0, 0])
self.assertEqual(expect2, diff[0, 1])