BonanDing commited on
Commit
9923b06
·
1 Parent(s): c1cc91b

Keep reproduction train and eval fixes

Browse files
.gitignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local environment and editor folders
2
+ .*/
3
+ !.gitignore
4
+ !.gitattributes
5
+ !.github/
6
+
7
+ # Python/runtime caches
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Training, evaluation, and logging artifacts
16
+ outputs/
17
+ output/
18
+ checkpoints/
19
+ lightning_logs/
20
+ logs/
21
+ slurm_logs/
22
+ wandb/
23
+ ablation_results/
24
+ latest-run
25
+ *.log
26
+
27
+ # Local model/data clones and generated assets
28
+ open-oasis/
29
+ proposal/
30
+ tools/
31
+ *.docx
32
+
33
+ # Local reproduction and ablation helper scripts
34
+ benchmark_memory_latency.sh
35
+ evaluate_600_prefix_mem_len_*.sh
36
+ evaluate_dense_reference*.sh
37
+ evaluate_dit_no_memory.sh
38
+ evaluate_oasis_baseline.sh
39
+ evaluate_unified.sh
40
+ infer_dit_no_memory.sh
41
+ infer_oasis_baseline.sh
42
+ run_memory_*.sh
43
+ train_debug_berzulis.sh
44
+ train_stage_1_debug.sh
45
+ utils/replay_offline_wandb.py
46
+
47
+ # Local experimental modules/backups kept out of the clean branch
48
+ algorithms/worldmem/*.bak_*
49
+ algorithms/worldmem/causal_memory.py
50
+ algorithms/worldmem/retrieval.py
51
+ algorithms/worldmem/revisit_consistency.py
52
+ algorithms/worldmem/models/causal_memory.py
algorithms/worldmem/df_video.py CHANGED
@@ -3,6 +3,7 @@ import random
3
  import math
4
  import numpy as np
5
  import torch
 
6
  import torch.nn.functional as F
7
  import torchvision.transforms.functional as TF
8
  from torchvision.transforms import InterpolationMode
@@ -376,7 +377,7 @@ class WorldMemMinecraft(DiffusionForcingBase):
376
  ref_mode=self.ref_mode
377
  )
378
 
379
- self.validation_lpips_model = LearnedPerceptualImagePatchSimilarity()
380
  vae = VAE_models["vit-l-20-shallow-encoder"]()
381
  self.vae = vae.eval()
382
 
@@ -475,65 +476,78 @@ class WorldMemMinecraft(DiffusionForcingBase):
475
 
476
  return {"loss": loss}
477
 
478
- def on_validation_epoch_end(self, namespace="validation") -> None:
479
- if not self.validation_step_outputs:
480
- return
481
-
482
- xs_pred = []
483
- xs = []
484
- for pred, gt in self.validation_step_outputs:
485
- xs_pred.append(pred)
486
- xs.append(gt)
487
-
488
- xs_pred = torch.cat(xs_pred, 1)
489
- if gt is not None:
490
- xs = torch.cat(xs, 1)
491
- else:
492
- xs = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
- if self.logger and self.log_video:
495
- log_video(
496
- xs_pred,
497
- xs,
498
- step=None if namespace == "test" else self.global_step,
499
- namespace=namespace + "_vis",
500
- context_frames=self.context_frames,
501
- logger=self.logger.experiment,
502
- save_local=self.save_local,
503
- local_save_dir=self.local_save_dir,
504
- )
505
 
506
- if xs is not None:
507
- # Move data to the same device as LPIPS model for metric calculation
508
- device = next(self.validation_lpips_model.parameters()).device
509
- xs_pred_device = xs_pred.to(device)
510
- xs_device = xs.to(device)
511
-
512
- metric_dict = get_validation_metrics_for_videos(
513
- xs_pred_device, xs_device,
514
- lpips_model=self.validation_lpips_model,
515
- lpips_batch_size=self.lpips_batch_size)
516
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  self.log_dict(
518
- {"mse": metric_dict['mse'],
519
- "psnr": metric_dict['psnr'],
520
- "lpips": metric_dict['lpips']},
521
- sync_dist=True
 
 
522
  )
523
 
524
- if self.log_curve:
525
- psnr_values = metric_dict['frame_wise_psnr'].cpu().tolist()
526
- frames = list(range(len(psnr_values)))
527
- line_plot = wandb.plot.line_series(
528
- xs = frames,
529
- ys = [psnr_values],
530
- keys = ["PSNR"],
531
- title = "Frame-wise PSNR",
532
- xname = "Frame index"
533
- )
534
-
535
- self.logger.experiment.log({"frame_wise_psnr_plot": line_plot})
536
-
537
  self.validation_step_outputs.clear()
538
 
539
  def _preprocess_batch(self, batch):
@@ -783,10 +797,24 @@ class WorldMemMinecraft(DiffusionForcingBase):
783
  xs_pred = self.decode(xs_pred[n_context_frames:].to(conditions.device))
784
  xs_decode = self.decode(xs[n_context_frames:].to(conditions.device))
785
 
786
- # Store results for evaluation (move to CPU to save GPU memory)
787
- self.validation_step_outputs.append((xs_pred.detach().cpu(), xs_decode.detach().cpu()))
 
 
 
 
 
 
 
 
 
 
 
788
  return
789
 
 
 
 
790
  @torch.no_grad()
791
  def interactive(self, first_frame, new_actions, first_pose, device,
792
  memory_latent_frames, memory_actions, memory_poses, memory_c2w, memory_frame_idx):
 
3
  import math
4
  import numpy as np
5
  import torch
6
+ import torch.distributed as dist
7
  import torch.nn.functional as F
8
  import torchvision.transforms.functional as TF
9
  from torchvision.transforms import InterpolationMode
 
377
  ref_mode=self.ref_mode
378
  )
379
 
380
+ self.validation_lpips_model = LearnedPerceptualImagePatchSimilarity(sync_on_compute=False)
381
  vae = VAE_models["vit-l-20-shallow-encoder"]()
382
  self.vae = vae.eval()
383
 
 
476
 
477
  return {"loss": loss}
478
 
479
+ def on_validation_epoch_start(self) -> None:
480
+ self._reset_metric_accumulators()
481
+
482
+ def on_test_epoch_start(self) -> None:
483
+ self._reset_metric_accumulators()
484
+
485
+ def on_validation_epoch_end(self) -> None:
486
+ self._on_eval_epoch_end()
487
+
488
+ def on_test_epoch_end(self) -> None:
489
+ self._on_eval_epoch_end()
490
+
491
+ def _reset_metric_accumulators(self) -> None:
492
+ self._metric_device = next(self.validation_lpips_model.parameters()).device
493
+ self._mse_sum = torch.tensor(0.0, device=self._metric_device)
494
+ self._mse_count = torch.tensor(0.0, device=self._metric_device)
495
+ self._psnr_sum = torch.tensor(0.0, device=self._metric_device)
496
+ self._psnr_count = torch.tensor(0.0, device=self._metric_device)
497
+ self._lpips_sum = torch.tensor(0.0, device=self._metric_device)
498
+ self._lpips_count = torch.tensor(0.0, device=self._metric_device)
499
+
500
+ def _update_metric_accumulators(self, xs_pred: torch.Tensor, xs_gt: torch.Tensor) -> None:
501
+ if not hasattr(self, "_metric_device"):
502
+ self._reset_metric_accumulators()
503
+
504
+ xs_pred_device = xs_pred.to(self._metric_device)
505
+ xs_gt_device = xs_gt.to(self._metric_device)
506
+ metric_dict = get_validation_metrics_for_videos(
507
+ xs_pred_device,
508
+ xs_gt_device,
509
+ lpips_model=self.validation_lpips_model,
510
+ lpips_batch_size=self.lpips_batch_size,
511
+ )
512
 
513
+ mse_count = torch.tensor(float(xs_pred_device.numel()), device=self._metric_device)
514
+ psnr_count = torch.tensor(float(xs_pred_device.shape[1]), device=self._metric_device)
515
+ lpips_count = torch.tensor(float(xs_pred_device.shape[0] * xs_pred_device.shape[1]), device=self._metric_device)
 
 
 
 
 
 
 
 
516
 
517
+ self._mse_sum += metric_dict["mse"].detach() * mse_count
518
+ self._mse_count += mse_count
519
+ self._psnr_sum += metric_dict["psnr"].detach() * psnr_count
520
+ self._psnr_count += psnr_count
521
+ self._lpips_sum += torch.tensor(float(metric_dict["lpips"]), device=self._metric_device) * lpips_count
522
+ self._lpips_count += lpips_count
523
+
524
+ del xs_pred_device, xs_gt_device
525
+
526
+ def _on_eval_epoch_end(self) -> None:
527
+ if not hasattr(self, "_metric_device"):
528
+ return
529
+
530
+ if dist.is_available() and dist.is_initialized():
531
+ for tensor in (
532
+ self._mse_sum,
533
+ self._mse_count,
534
+ self._psnr_sum,
535
+ self._psnr_count,
536
+ self._lpips_sum,
537
+ self._lpips_count,
538
+ ):
539
+ dist.all_reduce(tensor, op=dist.ReduceOp.SUM)
540
+
541
+ if self._mse_count.item() > 0:
542
  self.log_dict(
543
+ {
544
+ "mse": self._mse_sum / self._mse_count.clamp_min(1.0),
545
+ "psnr": self._psnr_sum / self._psnr_count.clamp_min(1.0),
546
+ "lpips": self._lpips_sum / self._lpips_count.clamp_min(1.0),
547
+ },
548
+ sync_dist=False,
549
  )
550
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  self.validation_step_outputs.clear()
552
 
553
  def _preprocess_batch(self, batch):
 
797
  xs_pred = self.decode(xs_pred[n_context_frames:].to(conditions.device))
798
  xs_decode = self.decode(xs[n_context_frames:].to(conditions.device))
799
 
800
+ if self.logger and self.log_video:
801
+ log_video(
802
+ xs_pred,
803
+ xs_decode,
804
+ step=batch_idx,
805
+ namespace=namespace + "_vis",
806
+ context_frames=self.context_frames,
807
+ logger=self.logger.experiment,
808
+ save_local=self.save_local,
809
+ local_save_dir=self.local_save_dir,
810
+ )
811
+
812
+ self._update_metric_accumulators(xs_pred, xs_decode)
813
  return
814
 
815
+ def test_step(self, batch, batch_idx) -> STEP_OUTPUT:
816
+ return self.validation_step(batch, batch_idx, namespace="test")
817
+
818
  @torch.no_grad()
819
  def interactive(self, first_frame, new_actions, first_pose, device,
820
  memory_latent_frames, memory_actions, memory_poses, memory_c2w, memory_frame_idx):
configurations/experiment/base_pytorch.yaml CHANGED
@@ -35,7 +35,7 @@ validation:
35
  val_every_n_step: 2000 # controls how frequent do we run validation, can be float (fraction of epoches) or int (steps) or null (if val_every_n_epoch is set)
36
  val_every_n_epoch: null # if you want to do validation every n epoches, requires val_every_n_step to be null.
37
  limit_batch: null # if null, run through validation set. Otherwise limit the number of batches to use for validation.
38
- inference_mode: True # whether to run validation in inference mode (enable_grad won't work!)
39
  data:
40
  num_workers: 4 # number of CPU threads for data preprocessing, for validation.
41
  shuffle: False # whether validation data will be shuffled
@@ -45,6 +45,7 @@ test:
45
  compile: False # whether to compile the model with torch.compile
46
  batch_size: 4 # test batch size per GPU; effective batch size is this number * gpu * nodes iff using distributed training
47
  limit_batch: null # if null, run through test set. Otherwise limit the number of batches to use for test.
 
48
  data:
49
  num_workers: 4 # number of CPU threads for data preprocessing, for test.
50
  shuffle: False # whether test data will be shuffled
 
35
  val_every_n_step: 2000 # controls how frequent do we run validation, can be float (fraction of epoches) or int (steps) or null (if val_every_n_epoch is set)
36
  val_every_n_epoch: null # if you want to do validation every n epoches, requires val_every_n_step to be null.
37
  limit_batch: null # if null, run through validation set. Otherwise limit the number of batches to use for validation.
38
+ inference_mode: False # whether to run validation in inference mode (enable_grad won't work!)
39
  data:
40
  num_workers: 4 # number of CPU threads for data preprocessing, for validation.
41
  shuffle: False # whether validation data will be shuffled
 
45
  compile: False # whether to compile the model with torch.compile
46
  batch_size: 4 # test batch size per GPU; effective batch size is this number * gpu * nodes iff using distributed training
47
  limit_batch: null # if null, run through test set. Otherwise limit the number of batches to use for test.
48
+ inference_mode: False # whether to run test in inference mode (enable_grad won't work!)
49
  data:
50
  num_workers: 4 # number of CPU threads for data preprocessing, for test.
51
  shuffle: False # whether test data will be shuffled
evaluate.sh CHANGED
@@ -20,4 +20,4 @@ python -m main +name=infer \
20
  +algorithm.n_tokens=8 \
21
  algorithm.context_frames=600 \
22
  experiment.test.batch_size=1 \
23
- experiment.test.limit_batch=10 \
 
20
  +algorithm.n_tokens=8 \
21
  algorithm.context_frames=600 \
22
  experiment.test.batch_size=1 \
23
+ experiment.test.limit_batch=10
experiments/exp_base.py CHANGED
@@ -9,6 +9,7 @@ from abc import ABC, abstractmethod
9
  from typing import Optional, Union, Literal, List, Dict
10
  import pathlib
11
  import os
 
12
 
13
  import hydra
14
  import torch
@@ -361,7 +362,7 @@ class BaseLightningExperiment(BaseExperiment):
361
  logger=self.logger,
362
  devices="auto",
363
  num_nodes=self.cfg.num_nodes,
364
- strategy=DDPStrategy(find_unused_parameters=False) if torch.cuda.device_count() > 1 else "auto",
365
  callbacks=callbacks,
366
  limit_val_batches=self.cfg.validation.limit_batch,
367
  precision=self.cfg.validation.precision,
@@ -415,10 +416,11 @@ class BaseLightningExperiment(BaseExperiment):
415
  logger=self.logger,
416
  devices="auto",
417
  num_nodes=self.cfg.num_nodes,
418
- strategy=DDPStrategy(find_unused_parameters=False) if torch.cuda.device_count() > 1 else "auto",
419
  callbacks=callbacks,
420
  limit_test_batches=self.cfg.test.limit_batch,
421
  precision=self.cfg.test.precision,
 
422
  detect_anomaly=False, # self.cfg.debug,
423
  )
424
 
 
9
  from typing import Optional, Union, Literal, List, Dict
10
  import pathlib
11
  import os
12
+ from datetime import timedelta
13
 
14
  import hydra
15
  import torch
 
362
  logger=self.logger,
363
  devices="auto",
364
  num_nodes=self.cfg.num_nodes,
365
+ strategy=DDPStrategy(find_unused_parameters=False, timeout=timedelta(hours=1)) if torch.cuda.device_count() > 1 else "auto",
366
  callbacks=callbacks,
367
  limit_val_batches=self.cfg.validation.limit_batch,
368
  precision=self.cfg.validation.precision,
 
416
  logger=self.logger,
417
  devices="auto",
418
  num_nodes=self.cfg.num_nodes,
419
+ strategy=DDPStrategy(find_unused_parameters=False, timeout=timedelta(hours=1)) if torch.cuda.device_count() > 1 else "auto",
420
  callbacks=callbacks,
421
  limit_test_batches=self.cfg.test.limit_batch,
422
  precision=self.cfg.test.precision,
423
+ inference_mode=self.cfg.test.inference_mode,
424
  detect_anomaly=False, # self.cfg.debug,
425
  )
426
 
main.py CHANGED
@@ -59,6 +59,7 @@ def run_local(cfg: DictConfig):
59
  OmegaConf.set_readonly(hydra_cfg, True)
60
 
61
  output_dir = Path(hydra_cfg.runtime.output_dir)
 
62
 
63
  if is_rank_zero:
64
  print(cyan(f"Outputs will be saved to:"), output_dir)
 
59
  OmegaConf.set_readonly(hydra_cfg, True)
60
 
61
  output_dir = Path(hydra_cfg.runtime.output_dir)
62
+ output_dir.mkdir(parents=True, exist_ok=True)
63
 
64
  if is_rank_zero:
65
  print(cyan(f"Outputs will be saved to:"), output_dir)
train_stage_1.sh CHANGED
@@ -22,7 +22,7 @@ python -m main +name=train \
22
  +algorithm.relative_embedding=true \
23
  +algorithm.log_video=true \
24
  +algorithm.add_timestamp_embedding=true \
25
- algorithm.metrics=[lpips,psnr] \
26
  experiment.training.checkpointing.every_n_train_steps=2500 \
27
  experiment.training.max_steps=120000
28
 
 
22
  +algorithm.relative_embedding=true \
23
  +algorithm.log_video=true \
24
  +algorithm.add_timestamp_embedding=true \
25
+ +algorithm.metrics=[lpips,psnr] \
26
  experiment.training.checkpointing.every_n_train_steps=2500 \
27
  experiment.training.max_steps=120000
28
 
train_stage_2.sh CHANGED
@@ -17,9 +17,9 @@ python -m main +name=train \
17
  +algorithm.relative_embedding=true \
18
  +algorithm.log_video=true \
19
  +algorithm.add_timestamp_embedding=true \
20
- algorithm.metrics=[lpips,psnr] \
21
  experiment.training.checkpointing.every_n_train_steps=2500 \
22
- resume=your_wandb_job_id e.g.yhht29bz \
23
- +output_dir=your_saving_path e.g. outputs/2025-05-18/15-16-32 \
24
  experiment.training.max_steps=240000
25
 
 
17
  +algorithm.relative_embedding=true \
18
  +algorithm.log_video=true \
19
  +algorithm.add_timestamp_embedding=true \
20
+ +algorithm.metrics=[lpips,psnr] \
21
  experiment.training.checkpointing.every_n_train_steps=2500 \
22
+ resume="${RESUME_RUN_ID:?Set RESUME_RUN_ID to the W&B run id from stage 1}" \
23
+ +output_dir="${OUTPUT_DIR:-outputs/worldmem_stage_2}" \
24
  experiment.training.max_steps=240000
25
 
train_stage_3.sh CHANGED
@@ -17,8 +17,8 @@ python -m main +name=train \
17
  +algorithm.relative_embedding=true \
18
  +algorithm.log_video=true \
19
  +algorithm.add_timestamp_embedding=true \
20
- algorithm.metrics=[lpips,psnr] \
21
  experiment.training.checkpointing.every_n_train_steps=2500 \
22
- resume=your_wandb_job_id e.g.yhht29bz \
23
- +output_dir=your_saving_path e.g. outputs/2025-05-18/15-16-32 \
24
- experiment.training.max_steps=700000
 
17
  +algorithm.relative_embedding=true \
18
  +algorithm.log_video=true \
19
  +algorithm.add_timestamp_embedding=true \
20
+ +algorithm.metrics=[lpips,psnr] \
21
  experiment.training.checkpointing.every_n_train_steps=2500 \
22
+ resume="${RESUME_RUN_ID:?Set RESUME_RUN_ID to the W&B run id from stage 2}" \
23
+ +output_dir="${OUTPUT_DIR:-outputs/worldmem_stage_3}" \
24
+ experiment.training.max_steps=700000
utils/distributed_utils.py CHANGED
@@ -1,3 +1,3 @@
1
- import wandb
2
 
3
- is_rank_zero = wandb.run is not None
 
1
+ import os
2
 
3
+ is_rank_zero = int(os.environ.get("RANK", 0)) == 0
utils/logging_utils.py CHANGED
@@ -67,79 +67,81 @@ def log_video(
67
  if not logger:
68
  logger = wandb
69
 
70
- # Prepare video tensors
71
- observation_hat_np = observation_hat.detach().cpu().numpy()
72
- if observation_gt is not None:
73
- observation_gt_np = observation_gt.detach().cpu().numpy()
74
- else:
75
- observation_gt_np = None
76
-
77
- # Normalize to 0-255
78
- observation_hat_np = np.transpose(np.clip(observation_hat_np, a_min=0.0, a_max=1.0) * 255, (1, 0, 2, 3, 4)).astype(np.uint8)
79
- if observation_gt_np is not None:
80
- observation_gt_np = np.transpose(np.clip(observation_gt_np, a_min=0.0, a_max=1.0) * 255, (1, 0, 2, 3, 4)).astype(np.uint8)
81
-
82
- n_samples = len(observation_hat_np)
83
-
 
84
  # Setup local save directory
85
  if save_local:
86
  if local_save_dir is None:
87
  try:
88
  hydra_cfg = hydra.core.hydra_config.HydraConfig.get()
89
  output_dir = Path(hydra_cfg.runtime.output_dir)
90
- except:
91
  output_dir = Path.cwd() / "outputs"
92
  local_save_dir = output_dir / "videos" / namespace
93
  else:
94
  local_save_dir = Path(local_save_dir)
95
-
96
  local_save_dir.mkdir(parents=True, exist_ok=True)
97
-
98
  # Save pred videos locally
99
  pred_dir = local_save_dir / "pred"
100
  pred_dir.mkdir(parents=True, exist_ok=True)
101
-
102
  # Save gt videos locally if available
103
- if observation_gt_np is not None:
104
  gt_dir = local_save_dir / "gt"
105
  gt_dir.mkdir(parents=True, exist_ok=True)
106
-
107
  # Save videos
108
  for i in range(n_samples):
109
- video_pred = observation_hat_np[i] # (T, C, H, W)
110
-
 
 
 
 
 
 
111
  if save_local:
112
  # Save prediction video
113
  if step is not None:
114
  video_filename_pred = f"{prefix}_{i}_rank{local_rank}_step{step}.{format}"
115
  else:
116
  video_filename_pred = f"{prefix}_{i}_rank{local_rank}.{format}"
117
-
118
  video_path_pred = pred_dir / video_filename_pred
119
  _save_video_to_file(video_pred, str(video_path_pred), fps)
120
-
121
  # Save ground truth video if available
122
- if observation_gt_np is not None:
123
- video_gt = observation_gt_np[i]
124
  if step is not None:
125
  video_filename_gt = f"{prefix}_{i}_rank{local_rank}_step{step}.{format}"
126
  else:
127
  video_filename_gt = f"{prefix}_{i}_rank{local_rank}.{format}"
128
-
129
  video_path_gt = gt_dir / video_filename_gt
130
  _save_video_to_file(video_gt, str(video_path_gt), fps)
131
-
132
  # Log to wandb (only rank 0 to avoid duplicate logging)
133
- if local_rank == 0 and logger:
134
- # Concatenate pred and gt side by side for visualization
135
- if observation_gt_np is not None:
136
- video_combined = torch.cat([
137
- torch.from_numpy(observation_hat_np),
138
- torch.from_numpy(observation_gt_np)
139
- ], -2).numpy() # Concatenate along width
140
  logger.log(
141
  {
142
- f"{namespace}/{prefix}_{i}": wandb.Video(video_combined[i], fps=fps, format=format),
143
  f"trainer/global_step": step,
144
  }
145
  )
 
67
  if not logger:
68
  logger = wandb
69
 
70
+ n_samples = int(observation_hat.shape[1])
71
+
72
+ def _to_uint8_video(video_tensor):
73
+ return (
74
+ video_tensor.detach()
75
+ .clamp(0.0, 1.0)
76
+ .mul(255)
77
+ .to(torch.uint8)
78
+ .cpu()
79
+ .numpy()
80
+ )
81
+
82
+ is_rank_zero = local_rank == 0
83
+ save_local = bool(save_local and is_rank_zero)
84
+
85
  # Setup local save directory
86
  if save_local:
87
  if local_save_dir is None:
88
  try:
89
  hydra_cfg = hydra.core.hydra_config.HydraConfig.get()
90
  output_dir = Path(hydra_cfg.runtime.output_dir)
91
+ except Exception:
92
  output_dir = Path.cwd() / "outputs"
93
  local_save_dir = output_dir / "videos" / namespace
94
  else:
95
  local_save_dir = Path(local_save_dir)
96
+
97
  local_save_dir.mkdir(parents=True, exist_ok=True)
98
+
99
  # Save pred videos locally
100
  pred_dir = local_save_dir / "pred"
101
  pred_dir.mkdir(parents=True, exist_ok=True)
102
+
103
  # Save gt videos locally if available
104
+ if observation_gt is not None:
105
  gt_dir = local_save_dir / "gt"
106
  gt_dir.mkdir(parents=True, exist_ok=True)
107
+
108
  # Save videos
109
  for i in range(n_samples):
110
+ if not save_local and not (is_rank_zero and logger):
111
+ continue
112
+
113
+ video_pred = _to_uint8_video(observation_hat[:, i]) # (T, C, H, W)
114
+ video_gt = None
115
+ if observation_gt is not None:
116
+ video_gt = _to_uint8_video(observation_gt[:, i])
117
+
118
  if save_local:
119
  # Save prediction video
120
  if step is not None:
121
  video_filename_pred = f"{prefix}_{i}_rank{local_rank}_step{step}.{format}"
122
  else:
123
  video_filename_pred = f"{prefix}_{i}_rank{local_rank}.{format}"
124
+
125
  video_path_pred = pred_dir / video_filename_pred
126
  _save_video_to_file(video_pred, str(video_path_pred), fps)
127
+
128
  # Save ground truth video if available
129
+ if video_gt is not None:
 
130
  if step is not None:
131
  video_filename_gt = f"{prefix}_{i}_rank{local_rank}_step{step}.{format}"
132
  else:
133
  video_filename_gt = f"{prefix}_{i}_rank{local_rank}.{format}"
134
+
135
  video_path_gt = gt_dir / video_filename_gt
136
  _save_video_to_file(video_gt, str(video_path_gt), fps)
137
+
138
  # Log to wandb (only rank 0 to avoid duplicate logging)
139
+ if is_rank_zero and logger:
140
+ if video_gt is not None:
141
+ video_combined = np.concatenate([video_pred, video_gt], axis=-1)
 
 
 
 
142
  logger.log(
143
  {
144
+ f"{namespace}/{prefix}_{i}": wandb.Video(video_combined, fps=fps, format=format),
145
  f"trainer/global_step": step,
146
  }
147
  )