kiwhansong commited on
Commit
5eea811
·
1 Parent(s): 78c8e0b

finish task 1

Browse files
Files changed (3) hide show
  1. app.py +269 -75
  2. config.yaml +2 -2
  3. export.py +32 -0
app.py CHANGED
@@ -1,21 +1,24 @@
 
1
  from pathlib import Path
 
2
  import spaces
3
  import gradio as gr
4
- import imageio
5
  import torch
6
  from torchvision.datasets.utils import download_and_extract_archive
7
  from PIL import Image
8
  from omegaconf import OmegaConf
9
  from algorithms.dfot import DFoTVideoPose
 
10
  from utils.ckpt_utils import download_pretrained
11
  from utils.huggingface_utils import download_from_hf
12
  from datasets.video.utils.io import read_video
13
  from datasets.video import RealEstate10KAdvancedVideoDataset
14
- from export import export_to_video
15
 
16
  DATASET_URL = "https://huggingface.co/kiwhansong/DFoT/resolve/main/datasets/RealEstate10K_Tiny.tar.gz"
17
  DATASET_DIR = Path("data/real-estate-10k-tiny")
18
- LONG_LENGTH = 20 # seconds
19
 
20
  if not DATASET_DIR.exists():
21
  DATASET_DIR.mkdir(parents=True)
@@ -30,10 +33,6 @@ metadata = torch.load(DATASET_DIR / "metadata" / "test.pt", weights_only=False)
30
  video_list = [
31
  read_video(path).permute(0, 3, 1, 2) / 255.0 for path in metadata["video_paths"]
32
  ]
33
- first_frame_list = [
34
- (video[0] * 255).permute(1, 2, 0).numpy().clip(0, 255).astype("uint8")
35
- for video in video_list
36
- ]
37
  poses_list = [
38
  torch.cat(
39
  [
@@ -48,6 +47,18 @@ poses_list = [
48
  )
49
  ]
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # pylint: disable-next=no-value-for-parameter
52
  dfot = DFoTVideoPose.load_from_checkpoint(
53
  checkpoint_path=download_pretrained("pretrained:DFoT_RE10K.ckpt"),
@@ -55,14 +66,38 @@ dfot = DFoTVideoPose.load_from_checkpoint(
55
  ).eval()
56
  dfot.to("cuda")
57
 
 
58
  def prepare_long_gt_video(idx: int):
59
  video = video_list[idx]
60
  indices = torch.linspace(0, video.size(0) - 1, LONG_LENGTH * 10, dtype=torch.long)
61
  return export_to_video(video[indices], fps=10)
62
 
63
- @spaces.GPU(duration=120)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  @torch.no_grad()
65
- def single_image_to_long_video(idx: int, guidance_scale: float, fps: int, progress=gr.Progress(track_tqdm=True)):
 
 
66
  video = video_list[idx]
67
  poses = poses_list[idx]
68
  indices = torch.linspace(0, video.size(0) - 1, LONG_LENGTH * fps, dtype=torch.long)
@@ -80,6 +115,43 @@ def single_image_to_long_video(idx: int, guidance_scale: float, fps: int, progre
80
  return export_to_video(gen_video[0].detach().cpu(), fps=fps)
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # Create the Gradio Blocks
84
  with gr.Blocks(theme=gr.themes.Base(primary_hue="teal")) as demo:
85
  gr.HTML(
@@ -108,51 +180,199 @@ with gr.Blocks(theme=gr.themes.Base(primary_hue="teal")) as demo:
108
  value="🤗 Pretrained Models", link="https://huggingface.co/kiwhansong/DFoT"
109
  )
110
 
111
- with gr.Tab("Single Image Long Video", id="task-1"):
 
 
 
112
  gr.Markdown(
113
  """
114
- ## Demo 2: Single ImageLong Video
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  > #### **TL;DR:** _Diffusion Forcing Transformer, with History Guidance, can stably generate long videos, via sliding window rollouts and interpolation._
116
  """
117
  )
118
 
119
- stage = gr.State(value="Selection")
120
- selected_index = gr.State(value=None)
121
 
122
- @gr.render(inputs=[stage, selected_index])
123
  def render_stage(s, idx):
124
  match s:
125
  case "Selection":
126
- image_gallery = gr.Gallery(
127
- value=first_frame_list,
128
- label="Select an image to animate",
129
- columns=[8],
130
- selected_index=idx,
131
- )
132
-
133
- @image_gallery.select(inputs=None, outputs=selected_index)
134
- def update_selection(selection: gr.SelectData):
135
- return selection.index
136
-
137
- select_button = gr.Button("Select")
138
-
139
- @select_button.click(inputs=selected_index, outputs=stage)
140
- def move_to_generation(idx: int):
141
- if idx is None:
142
- gr.Warning("Image not selected!")
143
- return "Selection"
144
- else:
145
- return "Generation"
 
 
 
 
 
 
146
 
147
  case "Generation":
148
  with gr.Row():
149
- gr.Image(value=first_frame_list[idx], label="Input Image")
150
- # gr.Video(value=metadata["video_paths"][idx], label="Ground Truth Video")
151
- gr.Video(value=prepare_long_gt_video(idx), label="Ground Truth Video")
152
- video = gr.Video(label="Generated Video")
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- with gr.Column():
155
- guidance_scale = gr.Slider(
 
 
156
  minimum=1,
157
  maximum=6,
158
  value=4,
@@ -161,7 +381,7 @@ with gr.Blocks(theme=gr.themes.Base(primary_hue="teal")) as demo:
161
  info="Without history guidance: 1.0; Recommended: 4.0",
162
  interactive=True,
163
  )
164
- fps = gr.Slider(
165
  minimum=1,
166
  maximum=10,
167
  value=4,
@@ -170,49 +390,23 @@ with gr.Blocks(theme=gr.themes.Base(primary_hue="teal")) as demo:
170
  info=f"A {LONG_LENGTH}-second video will be generated at this FPS; Decrease for faster generation; Increase for a smoother video",
171
  interactive=True,
172
  )
173
- generate_button = gr.Button("Generate Video").click(
174
  fn=single_image_to_long_video,
175
- inputs=[selected_index, guidance_scale, fps],
176
- outputs=video,
 
 
 
 
177
  )
178
- # def generate_video(idx: int):
179
- # gr.Video(value=single_image_to_long_video(idx))
180
-
181
- # Function to update the state with the selected index
182
-
183
- # def show_warning(selection: gr.SelectData):
184
- # gr.Warning(f"Your choice is #{selection.index}, with image: {selection.value['image']['path']}!")
185
-
186
- # # image_gallery.select(fn=show_warning, inputs=None)
187
-
188
- # # Show the generate button only if an image is selected
189
- # selected_index.change(fn=lambda idx: idx is not None, inputs=selected_index, outputs=generate_button)
190
-
191
- with gr.Tab("Any Images → Video", id="task-2"):
192
- gr.Markdown(
193
- """
194
- ## Demo 1: Any Images → Video
195
- > #### **TL;DR:** _Diffusion Forcing Transformer is a flexible model that can generate videos given variable number of context frames._
196
- """
197
- )
198
- input_text_1 = gr.Textbox(
199
- lines=2, placeholder="Enter text for Video Model 1..."
200
- )
201
- output_video_1 = gr.Video()
202
- generate_button_1 = gr.Button("Generate Video")
203
 
204
  with gr.Tab("Single Image → Extremely Long Video", id="task-3"):
205
  gr.Markdown(
206
  """
207
  ## Demo 3: Single Image → Extremely Long Video
208
- > #### **TL;DR:** _Diffusion Forcing Transformer is a flexible model that can generate videos given **variable number of context frames**._
209
  """
210
  )
211
- input_text_2 = gr.Textbox(
212
- lines=2, placeholder="Enter text for Video Model 2..."
213
- )
214
- output_video_2 = gr.Video()
215
- generate_button_2 = gr.Button("Generate Video")
216
 
217
  if __name__ == "__main__":
218
  demo.launch()
 
1
+ from typing import List
2
  from pathlib import Path
3
+ from functools import partial
4
  import spaces
5
  import gradio as gr
6
+ import numpy as np
7
  import torch
8
  from torchvision.datasets.utils import download_and_extract_archive
9
  from PIL import Image
10
  from omegaconf import OmegaConf
11
  from algorithms.dfot import DFoTVideoPose
12
+ from algorithms.dfot.history_guidance import HistoryGuidance
13
  from utils.ckpt_utils import download_pretrained
14
  from utils.huggingface_utils import download_from_hf
15
  from datasets.video.utils.io import read_video
16
  from datasets.video import RealEstate10KAdvancedVideoDataset
17
+ from export import export_to_video, export_to_gif, export_images_to_gif
18
 
19
  DATASET_URL = "https://huggingface.co/kiwhansong/DFoT/resolve/main/datasets/RealEstate10K_Tiny.tar.gz"
20
  DATASET_DIR = Path("data/real-estate-10k-tiny")
21
+ LONG_LENGTH = 20 # seconds
22
 
23
  if not DATASET_DIR.exists():
24
  DATASET_DIR.mkdir(parents=True)
 
33
  video_list = [
34
  read_video(path).permute(0, 3, 1, 2) / 255.0 for path in metadata["video_paths"]
35
  ]
 
 
 
 
36
  poses_list = [
37
  torch.cat(
38
  [
 
47
  )
48
  ]
49
 
50
+ first_frame_list = [
51
+ (video[0] * 255).permute(1, 2, 0).numpy().clip(0, 255).astype("uint8")
52
+ for video in video_list
53
+ ]
54
+ gif_paths = []
55
+ for idx, video, path in zip(
56
+ range(len(video_list)), video_list, metadata["video_paths"]
57
+ ):
58
+ indices = torch.linspace(0, video.size(0) - 1, 8, dtype=torch.long)
59
+ gif_paths.append(export_to_gif(video[indices], fps=4))
60
+
61
+
62
  # pylint: disable-next=no-value-for-parameter
63
  dfot = DFoTVideoPose.load_from_checkpoint(
64
  checkpoint_path=download_pretrained("pretrained:DFoT_RE10K.ckpt"),
 
66
  ).eval()
67
  dfot.to("cuda")
68
 
69
+
70
  def prepare_long_gt_video(idx: int):
71
  video = video_list[idx]
72
  indices = torch.linspace(0, video.size(0) - 1, LONG_LENGTH * 10, dtype=torch.long)
73
  return export_to_video(video[indices], fps=10)
74
 
75
+
76
+ def prepare_short_gt_video(idx: int):
77
+ video = video_list[idx]
78
+ indices = torch.linspace(0, video.size(0) - 1, 8, dtype=torch.long)
79
+ video = (
80
+ (video[indices].permute(0, 2, 3, 1) * 255).clamp(0, 255).to(torch.uint8).numpy()
81
+ )
82
+ return [video[i] for i in range(video.shape[0])]
83
+
84
+
85
+ def video_to_gif_and_images(video, indices):
86
+ masked_video = [
87
+ image if i in indices else np.zeros_like(image) for i, image in enumerate(video)
88
+ ]
89
+ return [(export_images_to_gif(masked_video), "GIF")] + [
90
+ (image, f"t={i}" if i in indices else "")
91
+ for i, image in enumerate(masked_video)
92
+ ]
93
+
94
+
95
+ @spaces.GPU(duration=300)
96
+ @torch.autocast("cuda")
97
  @torch.no_grad()
98
+ def single_image_to_long_video(
99
+ idx: int, guidance_scale: float, fps: int, progress=gr.Progress(track_tqdm=True)
100
+ ):
101
  video = video_list[idx]
102
  poses = poses_list[idx]
103
  indices = torch.linspace(0, video.size(0) - 1, LONG_LENGTH * fps, dtype=torch.long)
 
115
  return export_to_video(gen_video[0].detach().cpu(), fps=fps)
116
 
117
 
118
+ @spaces.GPU(duration=100)
119
+ @torch.autocast("cuda")
120
+ @torch.no_grad()
121
+ def any_images_to_short_video(
122
+ scene_idx: int,
123
+ image_indices: List[int],
124
+ guidance_scale: float,
125
+ progress=gr.Progress(track_tqdm=True),
126
+ ):
127
+ video = video_list[scene_idx]
128
+ poses = poses_list[scene_idx]
129
+ indices = torch.linspace(0, video.size(0) - 1, 8, dtype=torch.long)
130
+ xs = video[indices].unsqueeze(0).to("cuda")
131
+ conditions = poses[indices].unsqueeze(0).to("cuda")
132
+ gen_video = dfot._unnormalize_x(
133
+ dfot._sample_sequence(
134
+ batch_size=1,
135
+ context=dfot._normalize_x(xs),
136
+ context_mask=torch.tensor([i in image_indices for i in range(8)])
137
+ .unsqueeze(0)
138
+ .to("cuda"),
139
+ conditions=conditions,
140
+ history_guidance=HistoryGuidance.vanilla(
141
+ guidance_scale=guidance_scale,
142
+ visualize=False,
143
+ ),
144
+ )[0]
145
+ )
146
+ gen_video = (
147
+ (gen_video[0].detach().cpu().permute(0, 2, 3, 1) * 255)
148
+ .clamp(0, 255)
149
+ .to(torch.uint8)
150
+ .numpy()
151
+ )
152
+ return video_to_gif_and_images([image for image in gen_video], list(range(8)))
153
+
154
+
155
  # Create the Gradio Blocks
156
  with gr.Blocks(theme=gr.themes.Base(primary_hue="teal")) as demo:
157
  gr.HTML(
 
180
  value="🤗 Pretrained Models", link="https://huggingface.co/kiwhansong/DFoT"
181
  )
182
 
183
+ with gr.Accordion("Troubleshooting: not working or too slow?", open=False):
184
+ gr.Markdown("TODO")
185
+
186
+ with gr.Tab("Any # of Images → Short Video", id="task-1"):
187
  gr.Markdown(
188
  """
189
+ ## Demo 1: Any Number of Images Short 2-second Video
190
+ > #### **TL;DR:** _Diffusion Forcing Transformer is a flexible model that can generate videos given variable number of context frames._
191
+ """
192
+ )
193
+
194
+ demo1_stage = gr.State(value="Scene")
195
+ demo1_selected_scene_index = gr.State(value=None)
196
+ demo1_selected_image_indices = gr.State(value=[])
197
+
198
+ @gr.render(
199
+ inputs=[
200
+ demo1_stage,
201
+ demo1_selected_scene_index,
202
+ demo1_selected_image_indices,
203
+ ]
204
+ )
205
+ def render_stage(s, scene_idx, image_indices):
206
+ match s:
207
+ case "Scene":
208
+ with gr.Group():
209
+ demo1_scene_gallery = gr.Gallery(
210
+ height=300,
211
+ value=gif_paths,
212
+ label="Select a Scene to Generate Video",
213
+ columns=[8],
214
+ selected_index=scene_idx,
215
+ )
216
+
217
+ @demo1_scene_gallery.select(
218
+ inputs=None, outputs=demo1_selected_scene_index
219
+ )
220
+ def update_selection(selection: gr.SelectData):
221
+ return selection.index
222
+
223
+ demo1_scene_select_button = gr.Button("Select Scene")
224
+
225
+ @demo1_scene_select_button.click(
226
+ inputs=demo1_selected_scene_index, outputs=demo1_stage
227
+ )
228
+ def move_to_image_selection(scene_idx: int):
229
+ if scene_idx is None:
230
+ gr.Warning("Scene not selected!")
231
+ return "Scene"
232
+ else:
233
+ return "Image"
234
+
235
+ case "Image":
236
+ with gr.Group():
237
+ demo1_image_gallery = gr.Gallery(
238
+ height=150,
239
+ value=[
240
+ (image, f"t={i}")
241
+ for i, image in enumerate(
242
+ prepare_short_gt_video(scene_idx)
243
+ )
244
+ ],
245
+ label="Select Images to Animate",
246
+ columns=[8],
247
+ )
248
+
249
+ demo1_selector = gr.CheckboxGroup(
250
+ label="Select Any Number of Input Images",
251
+ info="Image-to-Video: Select t=0; Interpolation: Select t=0 and t=7",
252
+ choices=[(f"t={i}", i) for i in range(8)],
253
+ value=[],
254
+ )
255
+ demo1_image_select_button = gr.Button("Select Input Images")
256
+
257
+ @demo1_image_select_button.click(
258
+ inputs=[demo1_selector],
259
+ outputs=[demo1_stage, demo1_selected_image_indices],
260
+ )
261
+ def generate_video(selected_indices):
262
+ if len(selected_indices) == 0:
263
+ gr.Warning("Select at least one image!")
264
+ return "Image", []
265
+ else:
266
+ return "Generation", selected_indices
267
+
268
+ case "Generation":
269
+ with gr.Group():
270
+ gt_video = prepare_short_gt_video(scene_idx)
271
+
272
+ demo1_input_image_gallery = gr.Gallery(
273
+ height=150,
274
+ value=video_to_gif_and_images(gt_video, image_indices),
275
+ label="Input Images",
276
+ columns=[9],
277
+ )
278
+ demo1_generated_gallery = gr.Gallery(
279
+ height=150,
280
+ value=[],
281
+ label="Generated Video",
282
+ columns=[9],
283
+ )
284
+
285
+ demo1_ground_truth_gallery = gr.Gallery(
286
+ height=150,
287
+ value=video_to_gif_and_images(gt_video, list(range(8))),
288
+ label="Ground Truth Video",
289
+ columns=[9],
290
+ )
291
+ with gr.Sidebar():
292
+ gr.Markdown("### Sampling Parameters")
293
+ demo1_guidance_scale = gr.Slider(
294
+ minimum=1,
295
+ maximum=6,
296
+ value=4,
297
+ step=0.5,
298
+ label="History Guidance Scale",
299
+ info="Without history guidance: 1.0; Recommended: 4.0",
300
+ interactive=True,
301
+ )
302
+ gr.Button("Generate Video").click(
303
+ fn=any_images_to_short_video,
304
+ inputs=[
305
+ demo1_selected_scene_index,
306
+ demo1_selected_image_indices,
307
+ demo1_guidance_scale,
308
+ ],
309
+ outputs=demo1_generated_gallery,
310
+ )
311
+
312
+ with gr.Tab("Single Image → Long Video", id="task-2"):
313
+ gr.Markdown(
314
+ """
315
+ ## Demo 2: Single Image → Long 20-second Video
316
  > #### **TL;DR:** _Diffusion Forcing Transformer, with History Guidance, can stably generate long videos, via sliding window rollouts and interpolation._
317
  """
318
  )
319
 
320
+ demo2_stage = gr.State(value="Selection")
321
+ demo2_selected_index = gr.State(value=None)
322
 
323
+ @gr.render(inputs=[demo2_stage, demo2_selected_index])
324
  def render_stage(s, idx):
325
  match s:
326
  case "Selection":
327
+ with gr.Group():
328
+ demo2_image_gallery = gr.Gallery(
329
+ height=300,
330
+ value=first_frame_list,
331
+ label="Select an Image to Animate",
332
+ columns=[8],
333
+ selected_index=idx,
334
+ )
335
+
336
+ @demo2_image_gallery.select(
337
+ inputs=None, outputs=demo2_selected_index
338
+ )
339
+ def update_selection(selection: gr.SelectData):
340
+ return selection.index
341
+
342
+ demo2_select_button = gr.Button("Select Input Image")
343
+
344
+ @demo2_select_button.click(
345
+ inputs=demo2_selected_index, outputs=demo2_stage
346
+ )
347
+ def move_to_generation(idx: int):
348
+ if idx is None:
349
+ gr.Warning("Image not selected!")
350
+ return "Selection"
351
+ else:
352
+ return "Generation"
353
 
354
  case "Generation":
355
  with gr.Row():
356
+ gr.Image(
357
+ value=first_frame_list[idx],
358
+ label="Input Image",
359
+ width=256,
360
+ height=256,
361
+ )
362
+ gr.Video(
363
+ value=prepare_long_gt_video(idx),
364
+ label="Ground Truth Video",
365
+ width=256,
366
+ height=256,
367
+ )
368
+ demo2_video = gr.Video(
369
+ label="Generated Video", width=256, height=256
370
+ )
371
 
372
+ with gr.Sidebar():
373
+ gr.Markdown("### Sampling Parameters")
374
+
375
+ demo2_guidance_scale = gr.Slider(
376
  minimum=1,
377
  maximum=6,
378
  value=4,
 
381
  info="Without history guidance: 1.0; Recommended: 4.0",
382
  interactive=True,
383
  )
384
+ demo2_fps = gr.Slider(
385
  minimum=1,
386
  maximum=10,
387
  value=4,
 
390
  info=f"A {LONG_LENGTH}-second video will be generated at this FPS; Decrease for faster generation; Increase for a smoother video",
391
  interactive=True,
392
  )
393
+ gr.Button("Generate Video").click(
394
  fn=single_image_to_long_video,
395
+ inputs=[
396
+ demo2_selected_index,
397
+ demo2_guidance_scale,
398
+ demo2_fps,
399
+ ],
400
+ outputs=demo2_video,
401
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
  with gr.Tab("Single Image → Extremely Long Video", id="task-3"):
404
  gr.Markdown(
405
  """
406
  ## Demo 3: Single Image → Extremely Long Video
407
+ > #### **TL;DR:** _TODO._
408
  """
409
  )
 
 
 
 
 
410
 
411
  if __name__ == "__main__":
412
  demo.launch()
config.yaml CHANGED
@@ -119,9 +119,9 @@ tasks:
119
  enabled: false
120
  history_guidance:
121
  name: vanilla
122
- guidance_scale: 1.5
123
  visualize: False
124
- max_batch_size: 1
125
  logging:
126
  deterministic: null
127
  loss_freq: 100
 
119
  enabled: false
120
  history_guidance:
121
  name: vanilla
122
+ guidance_scale: 1
123
  visualize: False
124
+ max_batch_size: null
125
  logging:
126
  deterministic: null
127
  loss_freq: 100
export.py CHANGED
@@ -1,9 +1,41 @@
 
1
  import tempfile
 
2
  import torch
3
  from torch import Tensor
4
  from torchvision.io import write_video
 
5
 
6
  def export_to_video(tensor: Tensor, fps: int = 10) -> str:
7
  path = tempfile.NamedTemporaryFile(suffix=".mp4").name
8
  write_video(path, (tensor.permute(0, 2, 3, 1) * 255).clamp(0, 255).to(torch.uint8), fps=fps)
9
  return path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
  import tempfile
3
+ import numpy as np
4
  import torch
5
  from torch import Tensor
6
  from torchvision.io import write_video
7
+ from PIL import Image
8
 
9
  def export_to_video(tensor: Tensor, fps: int = 10) -> str:
10
  path = tempfile.NamedTemporaryFile(suffix=".mp4").name
11
  write_video(path, (tensor.permute(0, 2, 3, 1) * 255).clamp(0, 255).to(torch.uint8), fps=fps)
12
  return path
13
+
14
+ def export_to_gif(tensor: Tensor, fps: int = 4) -> str:
15
+ path = tempfile.NamedTemporaryFile(suffix=".gif").name
16
+ images = (tensor.permute(0, 2, 3, 1) * 255).clamp(0, 255).to(torch.uint8)
17
+ images = [Image.fromarray(image.numpy()) for image in images]
18
+
19
+ images[0].save(
20
+ path,
21
+ save_all=True,
22
+ append_images=images[1:],
23
+ optimize=False,
24
+ duration=1000 // fps,
25
+ loop=0,
26
+ )
27
+ return path
28
+
29
+ def export_images_to_gif(images: List[np.ndarray], fps: int = 4) -> str:
30
+ path = tempfile.NamedTemporaryFile(suffix=".gif").name
31
+ images = [Image.fromarray(image) for image in images]
32
+
33
+ images[0].save(
34
+ path,
35
+ save_all=True,
36
+ append_images=images[1:],
37
+ optimize=False,
38
+ duration=1000 // fps,
39
+ loop=0,
40
+ )
41
+ return path