├── 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 |
--------------------------------------------------------------------------------