From c04c86963c0d3739078bdda80a7b366a45c161d7 Mon Sep 17 00:00:00 2001 From: biosp4rk <37962487+biosp4rk@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:58:08 -0800 Subject: [PATCH 1/4] Implement color variation --- src/mars_patcher/palette.py | 99 ++++++++++++++++++++++++----- src/mars_patcher/random_palettes.py | 90 +++++++++++++++----------- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/src/mars_patcher/palette.py b/src/mars_patcher/palette.py index 79bedde..b7ecb28 100644 --- a/src/mars_patcher/palette.py +++ b/src/mars_patcher/palette.py @@ -1,9 +1,83 @@ import math +import random +from enum import Enum -from mars_patcher.color_spaces import RgbBitSize, RgbColor +from mars_patcher.color_spaces import HsvColor, OklabColor, RgbBitSize, RgbColor from mars_patcher.rom import Rom +class VariationType(Enum): + ADD = 0 + MULTIPLY = 1 + + +class PaletteVariation: + def __init__(self, start: float, step: float): + self.start = start + self.step = step + + def get_at(self, index: int) -> float: + assert 0 <= index < 16 + return self.start + (index * self.step) + + @staticmethod + def generate(max_range: float, type: VariationType) -> "PaletteVariation": + """ + Generates a list of 16 floats that vary within a specified range, with + values that either increase or decrease. + + Add example 1: [-40, -35, ..., -5, 0, ..., 30, 35] + Add example 2: [0.08, 0.06, ..., -0.06, -0.08, ..., -0.20, -0.22] + Multiply example 1: [0.60, 0.65, ..., 0.95, 1.00, ..., 1.30, 1.35] + Multipy example 2: [1.08, 1.06, ..., 0.94, 0.92, ..., 0.80, 0.78] + """ + assert max_range >= 0.0 + # Generate random value between 0 and max_range + var_range = random.uniform(max_range / 4, max_range) + # var_range = max_range + if type == VariationType.ADD: + start = random.uniform(-var_range, 0) + elif type == VariationType.MULTIPLY: + start = random.uniform(1.0 - var_range, 1.0) + else: + raise ValueError("Invalid VariationType") + step = var_range / 16.0 + # Choose randomly between increasing or decreasing values + if random.choice([True, False]): + start += 15 * step + step = -step + return PaletteVariation(start, step) + + +class ColorChange: + def __init__( + self, hue_shift: float, hue_var: PaletteVariation, lightness_var: PaletteVariation + ): + self.hue_shift = hue_shift + self.hue_var = hue_var + self.lightness_var = lightness_var + + def change_hsv(self, hsv: HsvColor, index: int) -> HsvColor: + shift = self.hue_shift + if self.hue_var is not None: + shift += self.hue_var.get_at(index) + hsv.hue = (hsv.hue + shift) % 360 + if self.lightness_var is not None: + hsv.value = min(hsv.value * self.lightness_var.get_at(index), 1.0) + return hsv + + def change_oklab(self, lab: OklabColor, index: int) -> OklabColor: + shift = self.hue_shift + if self.hue_var is not None: + shift += self.hue_var.get_at(index) + # Convert hue shift to radians + shift *= math.pi / 180 + lab = lab.shift_hue(shift) + if self.lightness_var is not None: + lab.l_star = min(lab.l_star * self.lightness_var.get_at(index), 1.0) + return lab + + class Palette: def __init__(self, rows: int, rom: Rom, addr: int): assert rows >= 1 @@ -31,11 +105,8 @@ def write(self, rom: Rom, addr: int) -> None: data = self.byte_data() rom.write_bytes(addr, data) - def shift_hue_hsv(self, shift: int, excluded_rows: set[int]) -> None: - """ - Shifts hue by the provided amount, measured in degrees. - Uses HSV color space. - """ + def change_colors_hsv(self, change: ColorChange, excluded_rows: set[int]) -> None: + """Apply a color change using HSV color space.""" black = RgbColor.black() white = RgbColor.white_5() for row in range(self.rows()): @@ -47,30 +118,24 @@ def shift_hue_hsv(self, shift: int, excluded_rows: set[int]) -> None: rgb = self.colors[offset + i] if rgb == black or rgb == white: continue - # Get HSV and shift hue orig_luma = rgb.luma() - hsv = rgb.hsv() - hsv.hue = (hsv.hue + shift) % 360 - # Get new RGB and rescale luma + hsv = change.change_hsv(rgb.hsv(), i) rgb = hsv.rgb() + # Rescale luma luma_ratio = orig_luma / rgb.luma() rgb.red = min(int(rgb.red * luma_ratio), 255) rgb.green = min(int(rgb.green * luma_ratio), 255) rgb.blue = min(int(rgb.blue * luma_ratio), 255) self.colors[offset + i] = rgb - def shift_hue_oklab(self, shift: int, excluded_rows: set[int]) -> None: - """ - Shifts hue by the provided amount, measured in degrees. - Uses Oklab color space. - """ + def change_colors_oklab(self, change: ColorChange, excluded_rows: set[int]) -> None: + """Apply a color change using Oklab color space.""" # Convert shift to radians - shift_rads = shift * (math.pi / 180) for row in range(self.rows()): if row in excluded_rows: continue offset = row * 16 for i in range(16): rgb = self.colors[offset + i] - lab = rgb.oklab().shift_hue(shift_rads) + lab = change.change_oklab(rgb.oklab(), i) self.colors[offset + i] = lab.rgb() diff --git a/src/mars_patcher/random_palettes.py b/src/mars_patcher/random_palettes.py index 37b1236..1215086 100644 --- a/src/mars_patcher/random_palettes.py +++ b/src/mars_patcher/random_palettes.py @@ -13,7 +13,7 @@ MF_TILESET_ALT_PAL_ROWS, TILESET_ANIM_PALS, ) -from mars_patcher.palette import Palette +from mars_patcher.palette import ColorChange, Palette, PaletteVariation, VariationType from mars_patcher.rom import Game, Rom @@ -38,11 +38,13 @@ def __init__( pal_types: dict[PaletteType, tuple[int, int]], # TODO: change this tuple(int, int) color_space: MarsschemaPalettesColorspace, symmetric: bool, + extra_variation: bool, ): self.seed = seed self.pal_types = pal_types self.color_space: MarsschemaPalettesColorspace = color_space self.symmetric = symmetric + self.extra_variation = extra_variation @classmethod def from_json(cls, data: MarsschemaPalettes) -> "PaletteSettings": @@ -55,7 +57,8 @@ def from_json(cls, data: MarsschemaPalettes) -> "PaletteSettings": pal_types[pal_type] = hue_range color_space = data.get("ColorSpace", "Oklab") symmetric = data.get("Symmetric", True) - return cls(seed, pal_types, color_space, symmetric) + # Extra variation is always enabled. This could be passed via JSON instead. + return cls(seed, pal_types, color_space, symmetric, True) @classmethod def get_hue_range(cls, data: MarsschemaPalettesRandomize) -> tuple[int, int]: @@ -81,26 +84,39 @@ def __init__(self, rom: Rom, settings: PaletteSettings): self.rom = rom self.settings = settings if settings.color_space == "HSV": - self.shift_func = self.shift_palette_hsv + self.change_func = self.change_palette_hsv elif settings.color_space == "Oklab": - self.shift_func = self.shift_palette_oklab + self.change_func = self.change_palette_oklab else: raise ValueError(f"Invalid color space '{settings.color_space}' for color space!") @staticmethod - def shift_palette_hsv(pal: Palette, shift: int, excluded_rows: set[int] = set()) -> None: - pal.shift_hue_hsv(shift, excluded_rows) + def change_palette_hsv( + pal: Palette, change: ColorChange, excluded_rows: set[int] = set() + ) -> None: + pal.change_colors_hsv(change, excluded_rows) @staticmethod - def shift_palette_oklab(pal: Palette, shift: int, excluded_rows: set[int] = set()) -> None: - pal.shift_hue_oklab(shift, excluded_rows) - - def get_hue_shift(self, hue_range: tuple[int, int]) -> int: - """Returns a hue shift in a random direction between hue_min and hue_max.""" - shift = random.randint(hue_range[0], hue_range[1]) - if self.settings.symmetric and random.random() < 0.5: - shift = 360 - shift - return shift + def change_palette_oklab( + pal: Palette, change: ColorChange, excluded_rows: set[int] = set() + ) -> None: + pal.change_colors_oklab(change, excluded_rows) + + def generate_palette_change(self, hue_range: tuple[int, int]) -> ColorChange: + """Generates a random color change. hue_range determines how far each color's hue will be + initially rotated. Individual colors can be additionally rotated up to half of the hue + range. Lightness/value is also varied for each color between 0.8 and 1.2""" + hue_shift = random.randint(hue_range[0], hue_range[1]) + if self.settings.symmetric and random.choice([True, False]): + hue_shift = 360 - hue_shift + if self.settings.extra_variation: + hue_var_range = (hue_range[1] - hue_range[0]) / 2 + hue_var = PaletteVariation.generate(hue_var_range, VariationType.ADD) + lightness_var = PaletteVariation.generate(0.2, VariationType.MULTIPLY) + else: + hue_var = None + lightness_var = None + return ColorChange(hue_shift, hue_var, lightness_var) def randomize(self) -> None: random.seed(self.settings.seed) @@ -119,24 +135,24 @@ def randomize(self) -> None: if self.rom.is_zm(): self.fix_zm_palettes() - def shift_palettes(self, pals: list[tuple[int, int]], shift: int) -> None: + def change_palettes(self, pals: list[tuple[int, int]], change: ColorChange) -> None: for addr, rows in pals: if addr in self.randomized_pals: continue pal = Palette(rows, self.rom, addr) - self.shift_func(pal, shift) + self.change_func(pal, change) pal.write(self.rom, addr) self.randomized_pals.add(addr) def randomize_samus(self, hue_range: tuple[int, int]) -> None: - shift = self.get_hue_shift(hue_range) - self.shift_palettes(gd.samus_palettes(self.rom), shift) - self.shift_palettes(gd.helmet_cursor_palettes(self.rom), shift) - self.shift_palettes(gd.sax_palettes(self.rom), shift) + change = self.generate_palette_change(hue_range) + self.change_palettes(gd.samus_palettes(self.rom), change) + self.change_palettes(gd.helmet_cursor_palettes(self.rom), change) + self.change_palettes(gd.sax_palettes(self.rom), change) def randomize_beams(self, hue_range: tuple[int, int]) -> None: - shift = self.get_hue_shift(hue_range) - self.shift_palettes(gd.beam_palettes(self.rom), shift) + change = self.generate_palette_change(hue_range) + self.change_palettes(gd.beam_palettes(self.rom), change) def randomize_tilesets(self, hue_range: tuple[int, int]) -> None: rom = self.rom @@ -160,22 +176,22 @@ def randomize_tilesets(self, hue_range: tuple[int, int]) -> None: excluded_rows = {row} # Load palette and shift hue pal = Palette(13, rom, pal_addr) - shift = self.get_hue_shift(hue_range) - self.shift_func(pal, shift, excluded_rows) + change = self.generate_palette_change(hue_range) + self.change_func(pal, change, excluded_rows) pal.write(rom, pal_addr) self.randomized_pals.add(pal_addr) # Check animated palette anim_pal_id = TILESET_ANIM_PALS.get(pal_addr) if anim_pal_id is not None: - self.randomize_anim_palette(anim_pal_id, shift) + self.randomize_anim_palette(anim_pal_id, change) anim_pal_to_randomize.remove(anim_pal_id) # Go through remaining animated palettes for anim_pal_id in anim_pal_to_randomize: - shift = self.get_hue_shift(hue_range) - self.randomize_anim_palette(anim_pal_id, shift) + change = self.generate_palette_change(hue_range) + self.randomize_anim_palette(anim_pal_id, change) - def randomize_anim_palette(self, anim_pal_id: int, shift: int) -> None: + def randomize_anim_palette(self, anim_pal_id: int, change: ColorChange) -> None: rom = self.rom addr = gd.anim_palette_entries(rom) + anim_pal_id * 8 pal_addr = rom.read_ptr(addr + 4) @@ -183,7 +199,7 @@ def randomize_anim_palette(self, anim_pal_id: int, shift: int) -> None: return rows = rom.read_8(addr + 2) pal = Palette(rows, rom, pal_addr) - self.shift_func(pal, shift) + self.change_func(pal, change) pal.write(rom, pal_addr) self.randomized_pals.add(pal_addr) @@ -197,18 +213,19 @@ def randomize_enemies(self, hue_range: tuple[int, int]) -> None: # Go through sprites in groups groups = ENEMY_GROUPS[rom.game] for _, sprite_ids in groups.items(): - shift = self.get_hue_shift(hue_range) + change = self.generate_palette_change(hue_range) for sprite_id in sprite_ids: assert sprite_id in to_randomize, f"{sprite_id:X} should be excluded" - self.randomize_enemy(sprite_id, shift) + self.randomize_enemy(sprite_id, change) to_randomize.remove(sprite_id) # Go through remaining sprites for sprite_id in to_randomize: - shift = self.get_hue_shift(hue_range) - self.randomize_enemy(sprite_id, shift) + change = self.generate_palette_change(hue_range) + self.randomize_enemy(sprite_id, change) - def randomize_enemy(self, sprite_id: int, shift: int) -> None: + def randomize_enemy(self, sprite_id: int, change: ColorChange) -> None: + # Get palette address and row count rom = self.rom sprite_gfx_id = sprite_id - 0x10 pal_ptr = gd.sprite_palette_ptrs(rom) @@ -229,8 +246,9 @@ def randomize_enemy(self, sprite_id: int, shift: int) -> None: rows = (rom.read_32(gfx_addr) >> 8) // 0x800 else: raise ValueError("Unknown game!") + # Load palette, change colors, and write to ROM pal = Palette(rows, rom, pal_addr) - self.shift_func(pal, shift) + self.change_func(pal, change) pal.write(rom, pal_addr) self.randomized_pals.add(pal_addr) From 40952100d9628ece2a5b2f964239942544e1140b Mon Sep 17 00:00:00 2001 From: biosp4rk <37962487+biosp4rk@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:04:21 -0800 Subject: [PATCH 2/4] Fix merge issue --- src/mars_patcher/random_palettes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mars_patcher/random_palettes.py b/src/mars_patcher/random_palettes.py index 436b10f..8beb890 100644 --- a/src/mars_patcher/random_palettes.py +++ b/src/mars_patcher/random_palettes.py @@ -253,7 +253,7 @@ def randomize_enemy(self, sprite_id: int, change: ColorChange) -> None: pal.write(rom, pal_addr) self.randomized_pals.add(pal_addr) if rom.is_mf() and sprite_id == 0x26: - self.fix_nettori(shift) + self.fix_nettori(change) def get_sprite_addr(self, sprite_id: int) -> int: addr = gd.sprite_palette_ptrs(self.rom) + (sprite_id - 0x10) * 4 @@ -263,11 +263,11 @@ def get_tileset_addr(self, sprite_id: int) -> int: addr = gd.tileset_entries(self.rom) + sprite_id * 0x14 + 4 return self.rom.read_ptr(addr) - def fix_nettori(self, shift: int) -> None: + def fix_nettori(self, change: ColorChange) -> None: """Nettori has extra palettes stored separately, so they require the same color change.""" for addr, rows in NETTORI_EXTRA_PALS: pal = Palette(rows, self.rom, addr) - self.shift_func(pal, shift) + self.change_func(pal, change) pal.write(self.rom, addr) def fix_zm_palettes(self) -> None: From 7ef58e1887159de576bd43f656b4f5fb78e1a630 Mon Sep 17 00:00:00 2001 From: biosp4rk <37962487+biosp4rk@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:05:46 -0800 Subject: [PATCH 3/4] Fix type error --- src/mars_patcher/palette.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mars_patcher/palette.py b/src/mars_patcher/palette.py index b7ecb28..f7f01bb 100644 --- a/src/mars_patcher/palette.py +++ b/src/mars_patcher/palette.py @@ -51,7 +51,10 @@ def generate(max_range: float, type: VariationType) -> "PaletteVariation": class ColorChange: def __init__( - self, hue_shift: float, hue_var: PaletteVariation, lightness_var: PaletteVariation + self, + hue_shift: float, + hue_var: PaletteVariation | None, + lightness_var: PaletteVariation | None, ): self.hue_shift = hue_shift self.hue_var = hue_var From 27d1daf1a330dede95061a9dd343504ab2c9f965 Mon Sep 17 00:00:00 2001 From: biosp4rk <37962487+biosp4rk@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:55:51 -0700 Subject: [PATCH 4/4] Use sine waves for extra hue variation --- src/mars_patcher/color_spaces.py | 8 +-- src/mars_patcher/palette.py | 89 ++++++++++++----------------- src/mars_patcher/random_palettes.py | 14 ++--- 3 files changed, 45 insertions(+), 66 deletions(-) diff --git a/src/mars_patcher/color_spaces.py b/src/mars_patcher/color_spaces.py index 12ba0fa..06ee321 100644 --- a/src/mars_patcher/color_spaces.py +++ b/src/mars_patcher/color_spaces.py @@ -266,19 +266,17 @@ def chroma(self) -> float: neutral gray of the same lightness.""" return math.sqrt(self.a_star * self.a_star + self.b_star * self.b_star) - def shift_hue(self, shift: float) -> "OklabColor": + def shift_hue(self, shift: float) -> None: """Shifts hue by the provided amount, measured in radians.""" # Get hue in range 0 to 2pi hue = self.hue() + math.pi hue = (hue + shift) % (2 * math.pi) # Put hue back in range -pi to pi hue -= math.pi - # Get new A and B values chroma = self.chroma() - a = chroma * math.cos(hue) - b = chroma * math.sin(hue) - return OklabColor(self.l_star, a, b) + self.a_star = chroma * math.cos(hue) + self.b_star = chroma * math.sin(hue) @staticmethod def linear_to_srgb(value: float) -> float: diff --git a/src/mars_patcher/palette.py b/src/mars_patcher/palette.py index f7f01bb..20b9cf0 100644 --- a/src/mars_patcher/palette.py +++ b/src/mars_patcher/palette.py @@ -1,83 +1,66 @@ import math import random -from enum import Enum from mars_patcher.color_spaces import HsvColor, OklabColor, RgbBitSize, RgbColor from mars_patcher.rom import Rom +HUE_VARIATION_RANGE = 180.0 +"""The maximum range that hue can be additionally rotated.""" -class VariationType(Enum): - ADD = 0 - MULTIPLY = 1 +class SineWave: + STEP = (2 * math.pi) / 16 -class PaletteVariation: - def __init__(self, start: float, step: float): - self.start = start - self.step = step - - def get_at(self, index: int) -> float: - assert 0 <= index < 16 - return self.start + (index * self.step) + def __init__(self, amplitude: float, frequency: float, phase: float): + self.amplitude = amplitude + self.frequency = frequency + self.phase = phase @staticmethod - def generate(max_range: float, type: VariationType) -> "PaletteVariation": + def generate(max_range: float) -> "SineWave": """ - Generates a list of 16 floats that vary within a specified range, with - values that either increase or decrease. - - Add example 1: [-40, -35, ..., -5, 0, ..., 30, 35] - Add example 2: [0.08, 0.06, ..., -0.06, -0.08, ..., -0.20, -0.22] - Multiply example 1: [0.60, 0.65, ..., 0.95, 1.00, ..., 1.30, 1.35] - Multipy example 2: [1.08, 1.06, ..., 0.94, 0.92, ..., 0.80, 0.78] + Generates a random sine wave of the form + y = amplitude * sin(frequency * x + phase) + where + 0 <= amplitude <= 1 + 1/4 <= frequency <= 1 + x increases in steps of 1/16 of a cycle + 0 <= phase <= 2pi (one cycle) """ - assert max_range >= 0.0 - # Generate random value between 0 and max_range - var_range = random.uniform(max_range / 4, max_range) - # var_range = max_range - if type == VariationType.ADD: - start = random.uniform(-var_range, 0) - elif type == VariationType.MULTIPLY: - start = random.uniform(1.0 - var_range, 1.0) - else: - raise ValueError("Invalid VariationType") - step = var_range / 16.0 - # Choose randomly between increasing or decreasing values - if random.choice([True, False]): - start += 15 * step - step = -step - return PaletteVariation(start, step) + assert 0 <= max_range <= 1 + # Prefer amplitudes closer to the max, otherwise the variation is often too subtle + amplitude = random.uniform(max_range / 2, max_range) + frequency = random.uniform(0.25, 1) + phase = random.uniform(0, 2 * math.pi) + return SineWave(amplitude, frequency, phase) + + def calculate_variation(self, x: int) -> float: + assert 0 <= x < 16 + return self.amplitude * math.sin(self.frequency * x * self.STEP + self.phase) class ColorChange: - def __init__( - self, - hue_shift: float, - hue_var: PaletteVariation | None, - lightness_var: PaletteVariation | None, - ): + def __init__(self, hue_shift: float, hue_var: SineWave | None): self.hue_shift = hue_shift self.hue_var = hue_var - self.lightness_var = lightness_var - def change_hsv(self, hsv: HsvColor, index: int) -> HsvColor: + def _get_hue_shift(self, index: int) -> float: shift = self.hue_shift if self.hue_var is not None: - shift += self.hue_var.get_at(index) + factor = HUE_VARIATION_RANGE / 2 + shift += self.hue_var.calculate_variation(index) * factor + return shift + + def change_hsv(self, hsv: HsvColor, index: int) -> HsvColor: + shift = self._get_hue_shift(index) hsv.hue = (hsv.hue + shift) % 360 - if self.lightness_var is not None: - hsv.value = min(hsv.value * self.lightness_var.get_at(index), 1.0) return hsv def change_oklab(self, lab: OklabColor, index: int) -> OklabColor: - shift = self.hue_shift - if self.hue_var is not None: - shift += self.hue_var.get_at(index) + shift = self._get_hue_shift(index) # Convert hue shift to radians shift *= math.pi / 180 - lab = lab.shift_hue(shift) - if self.lightness_var is not None: - lab.l_star = min(lab.l_star * self.lightness_var.get_at(index), 1.0) + lab.shift_hue(shift) return lab diff --git a/src/mars_patcher/random_palettes.py b/src/mars_patcher/random_palettes.py index 8beb890..2df5aff 100644 --- a/src/mars_patcher/random_palettes.py +++ b/src/mars_patcher/random_palettes.py @@ -14,7 +14,7 @@ NETTORI_EXTRA_PALS, TILESET_ANIM_PALS, ) -from mars_patcher.palette import ColorChange, Palette, PaletteVariation, VariationType +from mars_patcher.palette import ColorChange, Palette, SineWave from mars_patcher.rom import Game, Rom @@ -105,19 +105,17 @@ def change_palette_oklab( def generate_palette_change(self, hue_range: tuple[int, int]) -> ColorChange: """Generates a random color change. hue_range determines how far each color's hue will be - initially rotated. Individual colors can be additionally rotated up to half of the hue - range. Lightness/value is also varied for each color between 0.8 and 1.2""" + initially rotated. Individual colors can be additionally rotated using the values of a + random sine wave.""" hue_shift = random.randint(hue_range[0], hue_range[1]) if self.settings.symmetric and random.choice([True, False]): hue_shift = 360 - hue_shift if self.settings.extra_variation: - hue_var_range = (hue_range[1] - hue_range[0]) / 2 - hue_var = PaletteVariation.generate(hue_var_range, VariationType.ADD) - lightness_var = PaletteVariation.generate(0.2, VariationType.MULTIPLY) + hue_var_range = min(1.0, (hue_range[1] - hue_range[0]) / 180) + hue_var = SineWave.generate(hue_var_range) else: hue_var = None - lightness_var = None - return ColorChange(hue_shift, hue_var, lightness_var) + return ColorChange(hue_shift, hue_var) def randomize(self) -> None: random.seed(self.settings.seed)