├── LICENSE ├── README.md ├── environment.yml ├── rule_30_and_game_of_life.py └── video_writer.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Elliot Waite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rule 30 and Game of Life 2 | 3 | This code generates a 2D animation of a 1D cellular automaton, Rule 30 (or other rules), being fed as input to a 2D cellular automaton, Conway’s Game of Life. 4 | 5 | Video demo of Rule 30: https://youtu.be/IK7nBOLYzdE 6 | 7 | [](https://www.youtube.com/watch?v=IK7nBOLYzdE) 8 | 9 | Video demo of Rule 110: https://youtu.be/P2uhhAXd7PI 10 | 11 | [](https://www.youtube.com/watch?v=P2uhhAXd7PI) 12 | 13 | ## Requirements 14 | 15 | The following Python packages are required (I use a combination of Conda and Pip): 16 | ``` 17 | conda install colour imageio numpy opencv scipy tqdm 18 | 19 | pip install imutils 20 | ``` 21 | A version of ffmpeg that support the libx264 encoder is also required. I use the Homebrew version. 22 | 23 | To install [Homebrew](https://brew.sh/) (if you don't already have it installed): 24 | ``` 25 | /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 26 | ``` 27 | To install ffmpeg: 28 | ``` 29 | brew install ffmpeg 30 | ``` 31 | Note: If you want use a version of ffmpeg other than the Homebrew version, you'll have to change the `FFMPEG_PATH` value in `video_writer.py` to the path where your ffmpeg executable file is installed. 32 | 33 | ## Running the Code 34 | 35 | Just run: 36 | ``` 37 | python rule_30_and_game_of_life.py 38 | ``` 39 | 40 | To change the settings for the output video, just edit the constants at the top of the `rule_30_and_game_of_life.py` file. 41 | 42 | For example: 43 | ``` 44 | VIDEO_WIDTH = 3840 45 | VIDEO_HEIGHT = 2160 46 | SECS = int(60 * 3.5) # 3 mins 30 secs. 47 | PIXEL_SIZE = 6 48 | OUTPUT_PATH = 'videos/youtube-3m-30s-6px.mp4' 49 | 50 | FPS = 30 # Frames per second. 51 | HIGH_QUALITY = True 52 | ``` 53 | 54 | If you set `HIGH_QUALITY = False`, a slightly lower quality `.avi` video will be generated, but it will take less time to render, usually about half the time of the high-quality version. This is can be useful for generating preview versions when still experimenting with different settings. 55 | 56 | The low quality renderer uses OpenCV's VideoWriter. The high quality renderer writes all the frames to PNG image files, then combines those image files into a video using FFmpeg. 57 | 58 | ## License 59 | 60 | [MIT](LICENSE) 61 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | # To create: conda env create --file environment.yml 2 | # To update: conda env update --file environment.yml --prune 3 | # To remove: conda env remove --name rule-30-and-game-of-life --yes 4 | name: rule-30-and-game-of-life 5 | channels: 6 | - conda-forge 7 | - nodefaults 8 | dependencies: 9 | - python=3.12.* 10 | - colour 11 | - imageio 12 | - imutils 13 | - numpy 14 | - opencv 15 | - scipy 16 | - tqdm 17 | -------------------------------------------------------------------------------- /rule_30_and_game_of_life.py: -------------------------------------------------------------------------------- 1 | import colour 2 | import cv2 3 | import imutils 4 | import numpy as np 5 | from scipy import signal 6 | import tqdm 7 | 8 | import video_writer 9 | 10 | # # Instagram. 11 | # VIDEO_WIDTH = 1080 12 | # VIDEO_HEIGHT = 1350 13 | # SECS = 60 14 | # PIXEL_SIZE = 5 15 | # OUTPUT_PATH = 'videos/instagram-60s-5px.mp4' 16 | 17 | 18 | # # Twitter. 19 | # VIDEO_WIDTH = 1024 20 | # VIDEO_HEIGHT = 1024 21 | # SECS = 140 # 2 mins 20 secs. 22 | # PIXEL_SIZE = 4 23 | # OUTPUT_PATH = 'videos/twitter-2m-20s-4px.mp4' 24 | 25 | # YouTube. 26 | VIDEO_WIDTH = 3840 27 | VIDEO_HEIGHT = 2160 28 | SECS = int(60 * 3.5) # 3 mins 30 secs. 29 | PIXEL_SIZE = 6 30 | OUTPUT_PATH = 'videos/youtube-3m-30s-6px.mp4' 31 | 32 | # YouTube channel art. 33 | # VIDEO_WIDTH = 2880 34 | # VIDEO_HEIGHT = 1800 35 | # SECS = 60 36 | # PIXEL_SIZE = 4 37 | # OUTPUT_PATH = 'videos/youtube-channel-art-2.mp4' 38 | 39 | FPS = 60 # Frames per second. 40 | HIGH_QUALITY = True 41 | 42 | STATE_WIDTH = VIDEO_WIDTH // PIXEL_SIZE 43 | STATE_HEIGHT = VIDEO_HEIGHT // PIXEL_SIZE 44 | NUM_FRAMES = SECS * FPS 45 | 46 | # `RULE` specifies which cellular automaton rule to use. 47 | RULE = 30 48 | 49 | # `X_OFFSET` specifies how far from the center to place the initial first pixel. 50 | X_OFFSET = 0 51 | 52 | # These settings can be used for rule 110, which only grows to the left, so we 53 | # offset the starting pixel to be close to the right edge of the screen. 54 | # RULE = 110 55 | # X_OFFSET = VIDEO_WIDTH // PIXEL_SIZE // 2 - 1 - 60 * 4 56 | 57 | # The Game of Life state wraps across the left and right edges of the state, 58 | # and dies out at the top of the state (all values of the top row are zero). 59 | # By adding padding to the state, you extend the state beyond the edges of the 60 | # visible window, essentially hiding the wrapping and/or dying out aspects of 61 | # the state. 62 | GOL_STATE_WIDTH_PADDING = VIDEO_WIDTH 63 | GOL_STATE_HEIGHT_PADDING = VIDEO_HEIGHT 64 | 65 | 66 | class Rule30AndGameOfLife: 67 | def __init__(self, width, height, 68 | gol_percentage=0.5, 69 | num_frames=NUM_FRAMES): 70 | self.width = width 71 | self.height = height 72 | 73 | self.gol_height = int(height * gol_percentage) 74 | self.gol_state_width = self.width + GOL_STATE_WIDTH_PADDING * 2 75 | self.gol_state_height = self.gol_height + GOL_STATE_HEIGHT_PADDING 76 | 77 | self.gol_state = np.zeros((self.gol_state_height, self.gol_state_width), 78 | np.uint8) 79 | 80 | self.row_padding = num_frames // 2 81 | self.row_width = self.gol_state_width + self.row_padding * 2 82 | self.row = np.zeros(self.row_width, np.uint8) 83 | self.row[self.row_width // 2 + X_OFFSET] = 1 84 | 85 | self.rows_height = self.height - self.gol_height 86 | self.rows = np.concatenate(( 87 | np.zeros((self.rows_height - 1, self.gol_state_width), np.uint8), 88 | self.row[None, self.row_padding:-self.row_padding] 89 | )) 90 | 91 | self.row_neighbors = np.array([1, 2, 4], dtype=np.uint8) 92 | self.gol_neighbors = np.array([[1, 1, 1], 93 | [1, 0, 1], 94 | [1, 1, 1]], dtype=np.uint8) 95 | self.rule = RULE 96 | self.rule_kernel = None 97 | self.update_rule_kernel() 98 | 99 | hex_colors = [ 100 | '#711c91', 101 | '#ea00d9', 102 | '#0abdc6', 103 | '#133e7c', 104 | '#091833', 105 | '#000103' 106 | ] 107 | color_decay_times = [2 * 8 ** i for i in range(len(hex_colors) - 1)] 108 | assert len(hex_colors) == len(color_decay_times) + 1 109 | color_list = [colour.Color('white')] 110 | for i in range(len(hex_colors) - 1): 111 | color_list += list(colour.Color(hex_colors[i]).range_to( 112 | colour.Color(hex_colors[i + 1]), color_decay_times[i])) 113 | color_list += [colour.Color('black')] 114 | rgb_list = [c.rgb for c in color_list] 115 | 116 | self.colors = (np.array(rgb_list, np.float64) * 255).astype(np.uint8) 117 | 118 | self.decay = np.full((self.height, self.width), len(self.colors) - 1, 119 | np.int_) 120 | 121 | self.rgb = None 122 | 123 | self.update_decay() 124 | self.update_rgb() 125 | 126 | def step(self): 127 | self.update_rows_and_gol_state() 128 | self.update_decay() 129 | self.update_rgb() 130 | 131 | def update_rule_kernel(self): 132 | self.rule_kernel = np.array([int(x) for x in f'{self.rule:08b}'[::-1]], 133 | np.uint8) 134 | 135 | def update_rows_and_gol_state(self): 136 | # Update `rows` (the state of the 2D cellular automaton). 137 | rule_index = signal.convolve2d(self.row[None, :], 138 | self.row_neighbors[None, :], 139 | mode='same', boundary='wrap') 140 | self.row = self.rule_kernel[rule_index[0]] 141 | transfer_row = self.rows[:1] 142 | self.rows = np.concatenate(( 143 | self.rows[1:], 144 | self.row[None, self.row_padding:-self.row_padding] 145 | )) 146 | 147 | # Update `gol_state` (the state of the 3D cellular automaton). 148 | num_neighbors = signal.convolve2d(self.gol_state, self.gol_neighbors, 149 | mode='same', boundary='wrap') 150 | self.gol_state = np.logical_or(num_neighbors == 3, 151 | np.logical_and(num_neighbors == 2, 152 | self.gol_state) 153 | ).astype(np.uint8) 154 | 155 | self.gol_state = np.concatenate(( 156 | np.zeros((1, self.gol_state_width), np.uint8), 157 | self.gol_state[1:-1], 158 | transfer_row 159 | )) 160 | 161 | def update_decay(self): 162 | visible_state = np.concatenate( 163 | (self.gol_state[-self.gol_height:, 164 | GOL_STATE_WIDTH_PADDING:-GOL_STATE_WIDTH_PADDING], 165 | self.rows[:, GOL_STATE_WIDTH_PADDING:-GOL_STATE_WIDTH_PADDING]), 166 | axis=0) 167 | self.decay += 1 168 | self.decay = np.clip(self.decay, None, len(self.colors) - 1) 169 | self.decay *= 1 - visible_state 170 | 171 | def update_rgb(self): 172 | self.rgb = self.colors[self.decay] 173 | 174 | 175 | def main(): 176 | writer = video_writer.Writer(fps=FPS, high_quality=HIGH_QUALITY) 177 | 178 | animation = Rule30AndGameOfLife(STATE_WIDTH, STATE_HEIGHT) 179 | 180 | for _ in tqdm.trange(NUM_FRAMES): 181 | small_frame = animation.rgb 182 | enlarged_frame = imutils.resize(small_frame, VIDEO_WIDTH, VIDEO_HEIGHT, 183 | cv2.INTER_NEAREST) 184 | writer.add_frame(enlarged_frame) 185 | animation.step() 186 | 187 | writer.write(OUTPUT_PATH) 188 | 189 | 190 | if __name__ == '__main__': 191 | main() 192 | -------------------------------------------------------------------------------- /video_writer.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import pathlib 4 | import shutil 5 | import subprocess 6 | import tempfile 7 | 8 | from cv2 import VideoWriter 9 | from cv2 import VideoWriter_fourcc 10 | import imageio 11 | import numpy as np 12 | 13 | # Set `FFMPEG_PATH` to the local path of your installation of ffmpeg. 14 | # The conda version doesn't support the libx264 encoder, so I use the 15 | # Homebrew version: https://formulae.brew.sh/formula/ffmpeg 16 | FFMPEG_PATH = glob.glob('/usr/local/Cellar/ffmpeg/*/bin/ffmpeg')[-1] 17 | 18 | 19 | class Writer: 20 | """A class for creating video files from frames of Numpy arrays. 21 | 22 | Args 23 | fps: (int) The frames per second of the video. 24 | high_quality: (bool) If true, the quality of the output video will be 25 | higher, but it will take longer to render (about twice as long). 26 | The lower quality writer uses OpenCV's VideoWriter. 27 | The higher quality writer writes all the frames to PNG image files, 28 | then combines those image files into a video using FFmpeg. 29 | """ 30 | def __init__(self, fps, high_quality=True): 31 | self.writer = (HighQualityWriter(fps) if high_quality else 32 | LowQualityWriter(fps)) 33 | 34 | def add_frame(self, frame): 35 | """Adds a frame to the video. 36 | 37 | Args: 38 | frame: (uint8 Numpy array of shape: (video height, video width, 3)) 39 | The RGB data of the frame to add to the video. All frames must have 40 | the same width and height. 41 | """ 42 | self.writer.add_frame(frame) 43 | 44 | def write(self, output_path): 45 | """Writes the video file to the output path. 46 | 47 | Args: 48 | output_path: (string) The path where the video file will be saved to. 49 | """ 50 | self.writer.write(output_path) 51 | 52 | 53 | class LowQualityWriter: 54 | def __init__(self, fps=30): 55 | self.fps = fps 56 | self.tmp_dir = None 57 | self.tmp_video_path = None 58 | self.video_writer = None 59 | 60 | def _initialize_video(self, frame): 61 | self.tmp_dir = tempfile.TemporaryDirectory() 62 | self.tmp_video_path = os.path.join(self.tmp_dir.name, 'video.avi') 63 | fourcc = VideoWriter_fourcc(*'MJPG') 64 | height, width, _ = frame.shape 65 | self.video_writer = VideoWriter( 66 | self.tmp_video_path, fourcc, float(self.fps), (width, height)) 67 | 68 | def add_frame(self, frame): 69 | if self.tmp_dir is None: 70 | self._initialize_video(frame) 71 | 72 | self.video_writer.write(np.flip(frame, axis=2)) 73 | 74 | def write(self, output_path): 75 | self.video_writer.release() 76 | abs_output_path = pathlib.Path(output_path).with_suffix('.avi').absolute() 77 | os.makedirs(os.path.dirname(abs_output_path), exist_ok=True) 78 | shutil.move(self.tmp_video_path, abs_output_path) 79 | self.tmp_dir.cleanup() 80 | self.tmp_dir = None 81 | print(f'Video written to: {abs_output_path}') 82 | 83 | 84 | class HighQualityWriter: 85 | def __init__(self, fps=30): 86 | self.fps = fps 87 | self.tmp_dir = None 88 | self.cur_frame = 0 89 | 90 | def _initialize_video(self): 91 | self.tmp_dir = tempfile.TemporaryDirectory() 92 | self.cur_frame = 0 93 | 94 | def add_frame(self, frame): 95 | if self.tmp_dir is None: 96 | self._initialize_video() 97 | 98 | frame_path = os.path.join(self.tmp_dir.name, f'{self.cur_frame}.png') 99 | imageio.imwrite(frame_path, frame) 100 | self.cur_frame += 1 101 | 102 | def write(self, output_path): 103 | abs_tmp_dir_path = pathlib.Path(self.tmp_dir.name).absolute() 104 | abs_output_path = pathlib.Path(output_path).absolute() 105 | os.makedirs(os.path.dirname(abs_output_path), exist_ok=True) 106 | subprocess.call([FFMPEG_PATH, 107 | '-framerate', f'{self.fps}', # Frames per second. 108 | '-i', f'{abs_tmp_dir_path}/%d.png', # Input file pattern. 109 | '-vcodec', 'libx264', # Codec. 110 | 111 | # Ensures players can decode the H.264 format. 112 | '-pix_fmt', 'yuv420p', 113 | 114 | # Video quality, lower is better, but zero (lossless) 115 | # doesn't work. 116 | '-crf', '1', 117 | 118 | '-y', # Overwrite output files without asking. 119 | abs_output_path # Output path. 120 | ]) 121 | self.tmp_dir.cleanup() 122 | self.tmp_dir = None 123 | print(f'Video written to: {abs_output_path}') 124 | --------------------------------------------------------------------------------