|
| 1 | +import json |
| 2 | +import matplotlib.pyplot as plt |
| 3 | +import matplotlib.patches as patches |
| 4 | +from matplotlib.animation import FuncAnimation |
| 5 | +from matplotlib.animation import PillowWriter |
| 6 | +# from matplotlib.animation import ImageMagickWriter |
| 7 | +from PIL import Image |
| 8 | +from io import BytesIO |
| 9 | +import base64 |
| 10 | +import numpy as np |
| 11 | +import sys |
| 12 | +import os |
| 13 | +import tempfile |
| 14 | + |
| 15 | +sys.path.append(os.path.abspath(os.path.dirname(__file__))) |
| 16 | + |
| 17 | +# GIF parameters |
| 18 | +_STATES_PER_SECOND = 1.5 # Speed |
| 19 | +_START_DEFAULT = 0 |
| 20 | +_END_DEFAULT = 9999999 |
| 21 | +QUALITY_SETTINGS = { |
| 22 | + "low": {"interp_frames": 2}, |
| 23 | + "medium": {"interp_frames": 6}, |
| 24 | + "high": {"interp_frames": 10} |
| 25 | +} |
| 26 | + |
| 27 | +def apply_tint(image, color): |
| 28 | + r, g, b, a = np.rollaxis(image, axis=-1) |
| 29 | + r = np.clip(np.uint8(r * color[0]), 0, 255) |
| 30 | + g = np.clip(np.uint8(g * color[1]), 0, 255) |
| 31 | + b = np.clip(np.uint8(b * color[2]), 0, 255) |
| 32 | + tinted_image = np.stack([r, g, b, a], axis=-1) |
| 33 | + return tinted_image |
| 34 | + |
| 35 | + |
| 36 | +# This class is used due to a bug in PillowWriter where the output gif does not loop |
| 37 | +# More info can be found here: https://stackoverflow.com/questions/51512141/how-to-make-matplotlib-saved-gif-looping |
| 38 | +class LoopingPillowWriter(PillowWriter): |
| 39 | + def finish(self): |
| 40 | + self._frames[0].save( |
| 41 | + self._outfile, save_all=True, append_images=self._frames[1:], |
| 42 | + duration=int(1000 / self.fps), loop=0) |
| 43 | + |
| 44 | + |
| 45 | + |
| 46 | +def create_GIF(vfg_file, startStep=_START_DEFAULT, stopStep=_END_DEFAULT, quality='high'): |
| 47 | + |
| 48 | + # Choose the settings based on the quality provided |
| 49 | + chosen_quality = QUALITY_SETTINGS.get(quality, QUALITY_SETTINGS["high"]) |
| 50 | + num_interpolation_frames = chosen_quality["interp_frames"] |
| 51 | + |
| 52 | + with open(vfg_file, 'r') as f: |
| 53 | + data = json.load(f) |
| 54 | + |
| 55 | + startStep = max(0, min(startStep, len(data['visualStages']) - 1)) |
| 56 | + stopStep = max(startStep, min(stopStep, len(data['visualStages']) - 1)) |
| 57 | + visual_stages = data['visualStages'][startStep:stopStep+1] |
| 58 | + |
| 59 | + fig, ax = plt.subplots() |
| 60 | + ax.axis('equal') |
| 61 | + ax.axis('off') |
| 62 | + |
| 63 | + max_x = max(max(s['x'] + s['width'] for s in stage['visualSprites']) for stage in data['visualStages']) |
| 64 | + max_y = max(max(s['y'] + s['height'] for s in stage['visualSprites']) for stage in data['visualStages']) |
| 65 | + |
| 66 | + ax.set_xlim([0, max_x]) |
| 67 | + ax.set_ylim([0, max_y]) |
| 68 | + |
| 69 | + sprites = {} |
| 70 | + last_positions = {} |
| 71 | + image_table = {} |
| 72 | + tint_cache = {} |
| 73 | + |
| 74 | + image_keys = data.get("imageTable", {}).get("m_keys", []) |
| 75 | + image_values = data.get("imageTable", {}).get("m_values", []) |
| 76 | + |
| 77 | + for key, base64_str in zip(image_keys, image_values): |
| 78 | + try: |
| 79 | + image_data = base64.b64decode(base64_str) |
| 80 | + image = Image.open(BytesIO(image_data)) |
| 81 | + image = image.convert("RGBA") |
| 82 | + image_table[key] = np.array(image) |
| 83 | + except: |
| 84 | + print(f"Warning: Skipping '{key}' because its base64 value could not be decoded.") |
| 85 | + continue |
| 86 | + |
| 87 | + def update(frame): |
| 88 | + ax.clear() |
| 89 | + ax.axis('equal') |
| 90 | + ax.axis('off') |
| 91 | + ax.set_xlim([0, max_x]) |
| 92 | + ax.set_ylim([0, max_y]) |
| 93 | + |
| 94 | + # Determine whether we're in the pause frames |
| 95 | + active_frames = len(visual_stages) * num_interpolation_frames |
| 96 | + if frame >= active_frames: |
| 97 | + stage = visual_stages[-1] # Use the last visual stage |
| 98 | + interpolation_alpha = 1 # No need to interpolate for the pause frames |
| 99 | + else: |
| 100 | + interpolation_alpha = frame % num_interpolation_frames / (num_interpolation_frames - 1) |
| 101 | + stage_idx = frame // num_interpolation_frames |
| 102 | + stage = visual_stages[stage_idx] |
| 103 | + |
| 104 | + for sprite in stage['visualSprites']: |
| 105 | + x, y, w, h = sprite['x'], sprite['y'], sprite['width'], sprite['height'] |
| 106 | + color = (sprite['color']['r'], sprite['color']['g'], sprite['color']['b'], sprite['color']['a']) |
| 107 | + name = sprite['name'] |
| 108 | + |
| 109 | + if name in last_positions: |
| 110 | + x_last, y_last = last_positions[name] |
| 111 | + x = np.interp(interpolation_alpha, [0, 1], [x_last, x]) |
| 112 | + y = np.interp(interpolation_alpha, [0, 1], [y_last, y]) |
| 113 | + |
| 114 | + prefab_image = sprite.get('prefabimage', None) |
| 115 | + |
| 116 | + if prefab_image in image_table: |
| 117 | + cache_key = (prefab_image, tuple(color)) # Create a unique key for this image and color |
| 118 | + |
| 119 | + # Check if the tinted image is in the cache |
| 120 | + if cache_key not in tint_cache: |
| 121 | + image = np.array(image_table[prefab_image]) |
| 122 | + tinted_image = apply_tint(image, color) |
| 123 | + |
| 124 | + # Save the tinted image to the cache |
| 125 | + tint_cache[cache_key] = tinted_image |
| 126 | + |
| 127 | + # Use the tinted image from the cache |
| 128 | + ax.imshow(tint_cache[cache_key], extent=[x, x+w, y, y+h], origin='upper') |
| 129 | + |
| 130 | + else: |
| 131 | + if name not in sprites: |
| 132 | + sprites[name] = patches.Rectangle((x, y), w, h, linewidth=1, edgecolor='none', facecolor=color) |
| 133 | + ax.add_patch(sprites[name]) |
| 134 | + else: |
| 135 | + sprites[name].set_xy((x, y)) |
| 136 | + sprites[name].set_facecolor(color) |
| 137 | + ax.add_patch(sprites[name]) |
| 138 | + |
| 139 | + last_positions[name] = (x, y) |
| 140 | + |
| 141 | + if sprite['showname']: |
| 142 | + ax.text(x + w/2, y + h/2, name, ha='center', va='center') |
| 143 | + |
| 144 | + # add a few frames to ensure we can view the final state |
| 145 | + total_frames = len(visual_stages) * num_interpolation_frames + int(1 * num_interpolation_frames) |
| 146 | + fps = int(num_interpolation_frames * _STATES_PER_SECOND) |
| 147 | + |
| 148 | + ani = FuncAnimation(fig, update, frames=total_frames, repeat=True) |
| 149 | + |
| 150 | + # Create a tempFile, as FunctionAnimation cannot write to a BytesIO object (in-memory) and can only write to disk |
| 151 | + with tempfile.NamedTemporaryFile(delete=False, suffix=".gif") as tmpfile: |
| 152 | + tmpname = tmpfile.name |
| 153 | + |
| 154 | + writer = LoopingPillowWriter(fps=fps) |
| 155 | + ani.save(tmpname, writer=writer) |
| 156 | + |
| 157 | + # Read the content of the temporary file into a BytesIO object |
| 158 | + output = BytesIO() |
| 159 | + with open(tmpname, "rb") as f: |
| 160 | + output.write(f.read()) |
| 161 | + output.seek(0) |
| 162 | + |
| 163 | + # Remove the temporary file |
| 164 | + os.remove(tmpname) |
| 165 | + |
| 166 | + return output |
0 commit comments