Skip to content

Commit dd40dd3

Browse files
Merge pull request #124 from MetroidAdvRandomizerSystem/palette-variation
Palette variation experiment
2 parents cf70090 + 27d1daf commit dd40dd3

3 files changed

Lines changed: 126 additions & 61 deletions

File tree

src/mars_patcher/color_spaces.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,19 +266,17 @@ def chroma(self) -> float:
266266
neutral gray of the same lightness."""
267267
return math.sqrt(self.a_star * self.a_star + self.b_star * self.b_star)
268268

269-
def shift_hue(self, shift: float) -> "OklabColor":
269+
def shift_hue(self, shift: float) -> None:
270270
"""Shifts hue by the provided amount, measured in radians."""
271271
# Get hue in range 0 to 2pi
272272
hue = self.hue() + math.pi
273273
hue = (hue + shift) % (2 * math.pi)
274274
# Put hue back in range -pi to pi
275275
hue -= math.pi
276-
277276
# Get new A and B values
278277
chroma = self.chroma()
279-
a = chroma * math.cos(hue)
280-
b = chroma * math.sin(hue)
281-
return OklabColor(self.l_star, a, b)
278+
self.a_star = chroma * math.cos(hue)
279+
self.b_star = chroma * math.sin(hue)
282280

283281
@staticmethod
284282
def linear_to_srgb(value: float) -> float:

src/mars_patcher/palette.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,68 @@
11
import math
2+
import random
23

3-
from mars_patcher.color_spaces import RgbBitSize, RgbColor
4+
from mars_patcher.color_spaces import HsvColor, OklabColor, RgbBitSize, RgbColor
45
from mars_patcher.rom import Rom
56

7+
HUE_VARIATION_RANGE = 180.0
8+
"""The maximum range that hue can be additionally rotated."""
9+
10+
11+
class SineWave:
12+
STEP = (2 * math.pi) / 16
13+
14+
def __init__(self, amplitude: float, frequency: float, phase: float):
15+
self.amplitude = amplitude
16+
self.frequency = frequency
17+
self.phase = phase
18+
19+
@staticmethod
20+
def generate(max_range: float) -> "SineWave":
21+
"""
22+
Generates a random sine wave of the form
23+
y = amplitude * sin(frequency * x + phase)
24+
where
25+
0 <= amplitude <= 1
26+
1/4 <= frequency <= 1
27+
x increases in steps of 1/16 of a cycle
28+
0 <= phase <= 2pi (one cycle)
29+
"""
30+
assert 0 <= max_range <= 1
31+
# Prefer amplitudes closer to the max, otherwise the variation is often too subtle
32+
amplitude = random.uniform(max_range / 2, max_range)
33+
frequency = random.uniform(0.25, 1)
34+
phase = random.uniform(0, 2 * math.pi)
35+
return SineWave(amplitude, frequency, phase)
36+
37+
def calculate_variation(self, x: int) -> float:
38+
assert 0 <= x < 16
39+
return self.amplitude * math.sin(self.frequency * x * self.STEP + self.phase)
40+
41+
42+
class ColorChange:
43+
def __init__(self, hue_shift: float, hue_var: SineWave | None):
44+
self.hue_shift = hue_shift
45+
self.hue_var = hue_var
46+
47+
def _get_hue_shift(self, index: int) -> float:
48+
shift = self.hue_shift
49+
if self.hue_var is not None:
50+
factor = HUE_VARIATION_RANGE / 2
51+
shift += self.hue_var.calculate_variation(index) * factor
52+
return shift
53+
54+
def change_hsv(self, hsv: HsvColor, index: int) -> HsvColor:
55+
shift = self._get_hue_shift(index)
56+
hsv.hue = (hsv.hue + shift) % 360
57+
return hsv
58+
59+
def change_oklab(self, lab: OklabColor, index: int) -> OklabColor:
60+
shift = self._get_hue_shift(index)
61+
# Convert hue shift to radians
62+
shift *= math.pi / 180
63+
lab.shift_hue(shift)
64+
return lab
65+
666

767
class Palette:
868
def __init__(self, rows: int, rom: Rom, addr: int):
@@ -31,11 +91,8 @@ def write(self, rom: Rom, addr: int) -> None:
3191
data = self.byte_data()
3292
rom.write_bytes(addr, data)
3393

34-
def shift_hue_hsv(self, shift: int, excluded_rows: set[int]) -> None:
35-
"""
36-
Shifts hue by the provided amount, measured in degrees.
37-
Uses HSV color space.
38-
"""
94+
def change_colors_hsv(self, change: ColorChange, excluded_rows: set[int]) -> None:
95+
"""Apply a color change using HSV color space."""
3996
black = RgbColor.black()
4097
white = RgbColor.white_5()
4198
for row in range(self.rows()):
@@ -47,30 +104,24 @@ def shift_hue_hsv(self, shift: int, excluded_rows: set[int]) -> None:
47104
rgb = self.colors[offset + i]
48105
if rgb == black or rgb == white:
49106
continue
50-
# Get HSV and shift hue
51107
orig_luma = rgb.luma()
52-
hsv = rgb.hsv()
53-
hsv.hue = (hsv.hue + shift) % 360
54-
# Get new RGB and rescale luma
108+
hsv = change.change_hsv(rgb.hsv(), i)
55109
rgb = hsv.rgb()
110+
# Rescale luma
56111
luma_ratio = orig_luma / rgb.luma()
57112
rgb.red = min(int(rgb.red * luma_ratio), 255)
58113
rgb.green = min(int(rgb.green * luma_ratio), 255)
59114
rgb.blue = min(int(rgb.blue * luma_ratio), 255)
60115
self.colors[offset + i] = rgb
61116

62-
def shift_hue_oklab(self, shift: int, excluded_rows: set[int]) -> None:
63-
"""
64-
Shifts hue by the provided amount, measured in degrees.
65-
Uses Oklab color space.
66-
"""
117+
def change_colors_oklab(self, change: ColorChange, excluded_rows: set[int]) -> None:
118+
"""Apply a color change using Oklab color space."""
67119
# Convert shift to radians
68-
shift_rads = shift * (math.pi / 180)
69120
for row in range(self.rows()):
70121
if row in excluded_rows:
71122
continue
72123
offset = row * 16
73124
for i in range(16):
74125
rgb = self.colors[offset + i]
75-
lab = rgb.oklab().shift_hue(shift_rads)
126+
lab = change.change_oklab(rgb.oklab(), i)
76127
self.colors[offset + i] = lab.rgb()

src/mars_patcher/random_palettes.py

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
NETTORI_EXTRA_PALS,
1515
TILESET_ANIM_PALS,
1616
)
17-
from mars_patcher.palette import Palette
17+
from mars_patcher.palette import ColorChange, Palette, SineWave
1818
from mars_patcher.rom import Game, Rom
1919

2020

@@ -39,11 +39,13 @@ def __init__(
3939
pal_types: dict[PaletteType, tuple[int, int]], # TODO: change this tuple(int, int)
4040
color_space: MarsschemaPalettesColorspace,
4141
symmetric: bool,
42+
extra_variation: bool,
4243
):
4344
self.seed = seed
4445
self.pal_types = pal_types
4546
self.color_space: MarsschemaPalettesColorspace = color_space
4647
self.symmetric = symmetric
48+
self.extra_variation = extra_variation
4749

4850
@classmethod
4951
def from_json(cls, data: MarsschemaPalettes) -> "PaletteSettings":
@@ -56,7 +58,8 @@ def from_json(cls, data: MarsschemaPalettes) -> "PaletteSettings":
5658
pal_types[pal_type] = hue_range
5759
color_space = data.get("ColorSpace", "Oklab")
5860
symmetric = data.get("Symmetric", True)
59-
return cls(seed, pal_types, color_space, symmetric)
61+
# Extra variation is always enabled. This could be passed via JSON instead.
62+
return cls(seed, pal_types, color_space, symmetric, True)
6063

6164
@classmethod
6265
def get_hue_range(cls, data: MarsschemaPalettesRandomize) -> tuple[int, int]:
@@ -82,26 +85,37 @@ def __init__(self, rom: Rom, settings: PaletteSettings):
8285
self.rom = rom
8386
self.settings = settings
8487
if settings.color_space == "HSV":
85-
self.shift_func = self.shift_palette_hsv
88+
self.change_func = self.change_palette_hsv
8689
elif settings.color_space == "Oklab":
87-
self.shift_func = self.shift_palette_oklab
90+
self.change_func = self.change_palette_oklab
8891
else:
8992
raise ValueError(f"Invalid color space '{settings.color_space}' for color space!")
9093

9194
@staticmethod
92-
def shift_palette_hsv(pal: Palette, shift: int, excluded_rows: set[int] = set()) -> None:
93-
pal.shift_hue_hsv(shift, excluded_rows)
95+
def change_palette_hsv(
96+
pal: Palette, change: ColorChange, excluded_rows: set[int] = set()
97+
) -> None:
98+
pal.change_colors_hsv(change, excluded_rows)
9499

95100
@staticmethod
96-
def shift_palette_oklab(pal: Palette, shift: int, excluded_rows: set[int] = set()) -> None:
97-
pal.shift_hue_oklab(shift, excluded_rows)
98-
99-
def get_hue_shift(self, hue_range: tuple[int, int]) -> int:
100-
"""Returns a hue shift in a random direction between hue_min and hue_max."""
101-
shift = random.randint(hue_range[0], hue_range[1])
102-
if self.settings.symmetric and random.random() < 0.5:
103-
shift = 360 - shift
104-
return shift
101+
def change_palette_oklab(
102+
pal: Palette, change: ColorChange, excluded_rows: set[int] = set()
103+
) -> None:
104+
pal.change_colors_oklab(change, excluded_rows)
105+
106+
def generate_palette_change(self, hue_range: tuple[int, int]) -> ColorChange:
107+
"""Generates a random color change. hue_range determines how far each color's hue will be
108+
initially rotated. Individual colors can be additionally rotated using the values of a
109+
random sine wave."""
110+
hue_shift = random.randint(hue_range[0], hue_range[1])
111+
if self.settings.symmetric and random.choice([True, False]):
112+
hue_shift = 360 - hue_shift
113+
if self.settings.extra_variation:
114+
hue_var_range = min(1.0, (hue_range[1] - hue_range[0]) / 180)
115+
hue_var = SineWave.generate(hue_var_range)
116+
else:
117+
hue_var = None
118+
return ColorChange(hue_shift, hue_var)
105119

106120
def randomize(self) -> None:
107121
random.seed(self.settings.seed)
@@ -120,24 +134,24 @@ def randomize(self) -> None:
120134
if self.rom.is_zm():
121135
self.fix_zm_palettes()
122136

123-
def shift_palettes(self, pals: list[tuple[int, int]], shift: int) -> None:
137+
def change_palettes(self, pals: list[tuple[int, int]], change: ColorChange) -> None:
124138
for addr, rows in pals:
125139
if addr in self.randomized_pals:
126140
continue
127141
pal = Palette(rows, self.rom, addr)
128-
self.shift_func(pal, shift)
142+
self.change_func(pal, change)
129143
pal.write(self.rom, addr)
130144
self.randomized_pals.add(addr)
131145

132146
def randomize_samus(self, hue_range: tuple[int, int]) -> None:
133-
shift = self.get_hue_shift(hue_range)
134-
self.shift_palettes(gd.samus_palettes(self.rom), shift)
135-
self.shift_palettes(gd.helmet_cursor_palettes(self.rom), shift)
136-
self.shift_palettes(gd.sax_palettes(self.rom), shift)
147+
change = self.generate_palette_change(hue_range)
148+
self.change_palettes(gd.samus_palettes(self.rom), change)
149+
self.change_palettes(gd.helmet_cursor_palettes(self.rom), change)
150+
self.change_palettes(gd.sax_palettes(self.rom), change)
137151

138152
def randomize_beams(self, hue_range: tuple[int, int]) -> None:
139-
shift = self.get_hue_shift(hue_range)
140-
self.shift_palettes(gd.beam_palettes(self.rom), shift)
153+
change = self.generate_palette_change(hue_range)
154+
self.change_palettes(gd.beam_palettes(self.rom), change)
141155

142156
def randomize_tilesets(self, hue_range: tuple[int, int]) -> None:
143157
rom = self.rom
@@ -161,30 +175,30 @@ def randomize_tilesets(self, hue_range: tuple[int, int]) -> None:
161175
excluded_rows = {row}
162176
# Load palette and shift hue
163177
pal = Palette(13, rom, pal_addr)
164-
shift = self.get_hue_shift(hue_range)
165-
self.shift_func(pal, shift, excluded_rows)
178+
change = self.generate_palette_change(hue_range)
179+
self.change_func(pal, change, excluded_rows)
166180
pal.write(rom, pal_addr)
167181
self.randomized_pals.add(pal_addr)
168182
# Check animated palette
169183
anim_pal_id = TILESET_ANIM_PALS.get(pal_addr)
170184
if anim_pal_id is not None:
171-
self.randomize_anim_palette(anim_pal_id, shift)
185+
self.randomize_anim_palette(anim_pal_id, change)
172186
anim_pal_to_randomize.remove(anim_pal_id)
173187

174188
# Go through remaining animated palettes
175189
for anim_pal_id in anim_pal_to_randomize:
176-
shift = self.get_hue_shift(hue_range)
177-
self.randomize_anim_palette(anim_pal_id, shift)
190+
change = self.generate_palette_change(hue_range)
191+
self.randomize_anim_palette(anim_pal_id, change)
178192

179-
def randomize_anim_palette(self, anim_pal_id: int, shift: int) -> None:
193+
def randomize_anim_palette(self, anim_pal_id: int, change: ColorChange) -> None:
180194
rom = self.rom
181195
addr = gd.anim_palette_entries(rom) + anim_pal_id * 8
182196
pal_addr = rom.read_ptr(addr + 4)
183197
if pal_addr in self.randomized_pals:
184198
return
185199
rows = rom.read_8(addr + 2)
186200
pal = Palette(rows, rom, pal_addr)
187-
self.shift_func(pal, shift)
201+
self.change_func(pal, change)
188202
pal.write(rom, pal_addr)
189203
self.randomized_pals.add(pal_addr)
190204

@@ -198,18 +212,19 @@ def randomize_enemies(self, hue_range: tuple[int, int]) -> None:
198212
# Go through sprites in groups
199213
groups = ENEMY_GROUPS[rom.game]
200214
for _, sprite_ids in groups.items():
201-
shift = self.get_hue_shift(hue_range)
215+
change = self.generate_palette_change(hue_range)
202216
for sprite_id in sprite_ids:
203217
assert sprite_id in to_randomize, f"{sprite_id:X} should be excluded"
204-
self.randomize_enemy(sprite_id, shift)
218+
self.randomize_enemy(sprite_id, change)
205219
to_randomize.remove(sprite_id)
206220

207221
# Go through remaining sprites
208222
for sprite_id in to_randomize:
209-
shift = self.get_hue_shift(hue_range)
210-
self.randomize_enemy(sprite_id, shift)
223+
change = self.generate_palette_change(hue_range)
224+
self.randomize_enemy(sprite_id, change)
211225

212-
def randomize_enemy(self, sprite_id: int, shift: int) -> None:
226+
def randomize_enemy(self, sprite_id: int, change: ColorChange) -> None:
227+
# Get palette address and row count
213228
rom = self.rom
214229
sprite_gfx_id = sprite_id - 0x10
215230
pal_ptr = gd.sprite_palette_ptrs(rom)
@@ -230,12 +245,13 @@ def randomize_enemy(self, sprite_id: int, shift: int) -> None:
230245
rows = (rom.read_32(gfx_addr) >> 8) // 0x800
231246
else:
232247
raise ValueError("Unknown game!")
248+
# Load palette, change colors, and write to ROM
233249
pal = Palette(rows, rom, pal_addr)
234-
self.shift_func(pal, shift)
250+
self.change_func(pal, change)
235251
pal.write(rom, pal_addr)
236252
self.randomized_pals.add(pal_addr)
237253
if rom.is_mf() and sprite_id == 0x26:
238-
self.fix_nettori(shift)
254+
self.fix_nettori(change)
239255

240256
def get_sprite_addr(self, sprite_id: int) -> int:
241257
addr = gd.sprite_palette_ptrs(self.rom) + (sprite_id - 0x10) * 4
@@ -245,11 +261,11 @@ def get_tileset_addr(self, sprite_id: int) -> int:
245261
addr = gd.tileset_entries(self.rom) + sprite_id * 0x14 + 4
246262
return self.rom.read_ptr(addr)
247263

248-
def fix_nettori(self, shift: int) -> None:
264+
def fix_nettori(self, change: ColorChange) -> None:
249265
"""Nettori has extra palettes stored separately, so they require the same color change."""
250266
for addr, rows in NETTORI_EXTRA_PALS:
251267
pal = Palette(rows, self.rom, addr)
252-
self.shift_func(pal, shift)
268+
self.change_func(pal, change)
253269
pal.write(self.rom, addr)
254270

255271
def fix_zm_palettes(self) -> None:

0 commit comments

Comments
 (0)