| | """ |
| | Install dependencies: |
| | pip install pytorch360convert |
| | |
| | Example ffmpeg command to use on output frames: |
| | ffmpeg -framerate 60 -i output_frames/sweep360_%06d.png -c:v libx264 -pix_fmt yuv420p my_360_video.mp4 |
| | |
| | # Example for calculating FOV to use for specific dimensions |
| | import math |
| | width, height = 1280, 896 |
| | ratio = width / height |
| | vfov_deg = 70.0 |
| | vfov = math.radians(vfov_deg) |
| | hfov = 2 * math.atan(ratio * math.tan(vfov / 2)) |
| | hfov_deg = math.degrees(hfov) |
| | print(hfov_deg) # ~90.02° |
| | """ |
| |
|
| | import math |
| | import os |
| | from typing import Dict, List, Optional, Tuple, Union |
| |
|
| | import torch |
| | from pytorch360convert import e2p |
| | from PIL import Image |
| | import numpy as np |
| | from tqdm import tqdm |
| |
|
| |
|
| | def load_image_to_tensor(path: str, device: Optional[torch.device] = None) -> torch.Tensor: |
| | """ |
| | Load an image file to a float torch tensor in CHW format, range [0,1]. |
| | """ |
| | img = Image.open(path).convert("RGB") |
| | arr = np.array(img).astype(np.float32) / 255.0 |
| | t = torch.from_numpy(arr) |
| | t = t.permute(2, 0, 1) |
| | if device is not None: |
| | t = t.to(device) |
| | return t |
| |
|
| |
|
| | def _linear_progress(n_frames: int) -> List[float]: |
| | """ |
| | Generate a linear progression from 0.0 to 1.0 over n_frames. |
| | |
| | Args: |
| | n_frames (int): Number of frames. |
| | |
| | Returns: |
| | List[float]: List of normalized progress values. |
| | """ |
| | return [i / max(1, (n_frames - 1)) for i in range(n_frames)] |
| |
|
| |
|
| | def _ease_in_out_progress(n_frames: int) -> List[float]: |
| | """ |
| | Generate an ease-in-out progression (cosine smoothing) from 0.0 to 1.0. |
| | |
| | Args: |
| | n_frames (int): Number of frames. |
| | |
| | Returns: |
| | List[float]: List of normalized progress values. |
| | """ |
| | return [ |
| | 0.5 * (1 - math.cos(math.pi * (i / max(1, (n_frames - 1))))) |
| | for i in range(n_frames) |
| | ] |
| |
|
| |
|
| | def _save_tensor_as_image(tensor: torch.Tensor, path: str) -> None: |
| | """ |
| | Save a CHW float tensor (range [0, 1]) to directory |
| | """ |
| | if tensor.dim() == 4: |
| | tensor = tensor[0] |
| | tensor = tensor.permute(1, 2, 0) |
| | t = tensor.detach().cpu().clamp(0.0, 1.0) * 255.0 |
| | Image.fromarray(t.to(dtype=torch.uint8).numpy()).save(path) |
| |
|
| |
|
| | def generate_frames_from_equirect( |
| | equi_tensors: List[torch.Tensor], |
| | out_dir: str, |
| | resolution: Tuple[int, int] = (1080, 1920), |
| | fps: int = 30, |
| | duration_per_image: Optional[float] = 4.0, |
| | total_duration: Optional[float] = None, |
| | fov_deg: Union[float, Tuple[float, float]] = (70.0, 60.0), |
| | interpolation_mode: str = "bilinear", |
| | speed_profile: str = "constant", |
| | vertical_movement: Optional[Dict] = None, |
| | device: Optional[torch.device] = None, |
| | start_frame_index: int = 0, |
| | save_format: str = "png", |
| | start_yaw_deg: float = 0.0, |
| | end_yaw_deg: float = 360.0, |
| | filename_prefix: str = "frame", |
| | verbose: bool = True, |
| | ) -> List[str]: |
| | """ |
| | Generate video frames by sweeping through one or more equirectangular images. |
| | |
| | Args: |
| | equi_tensors (List[torch.Tensor]): List of equirectangular image tensors. |
| | out_dir (str): Output directory where frames will be saved. |
| | resolution (tuple of int): Output frame resolution as (height, width). Default: (1080, 1920) |
| | fps (int): Frames per second for timing calculations. Default: 30 |
| | duration_per_image (float): Duration in seconds for each image sweep. Default: 4.0 |
| | total_duration (float): Total duration in seconds for all images combined. Default: None |
| | fov_deg (float or tuple): Field of view in degrees. Default: (70.0, 60.0) |
| | interpolation_mode (str): Resampling interpolation. Options: "nearest", "bilinear", "bicubic". Default: "bilinear" |
| | speed_profile (str): Progression curve. Options: "constant", "ease_in_out". Default: "constant" |
| | vertical_movement (dict): Parameters for adding pitch movement. Default: None |
| | device (torch.device): Torch device to run on. Default: cpu |
| | start_frame_index (int): Starting frame index for naming. Default: 0 |
| | save_format (str): Image format. Options: "png", "jpg", "jpeg", "bmp". Default: "png" |
| | start_yaw_deg (float): Starting yaw angle in degrees. Default: 0.0 |
| | end_yaw_deg (float): Ending yaw angle in degrees. Default: 360.0 |
| | filename_prefix (str): Prefix for saved frame filenames. Default: "frame" |
| | verbose (bool): Print progress information. Default: True |
| | |
| | Returns: |
| | List[str]: List of file paths for the saved frames. |
| | """ |
| | os.makedirs(out_dir, exist_ok=True) |
| | device = device if device is not None else torch.device("cpu") |
| | saved_paths = [] |
| | n_images = len(equi_tensors) |
| |
|
| | if n_images == 0: |
| | return saved_paths |
| |
|
| | |
| | if total_duration is not None: |
| | assert total_duration > 0 |
| | seconds_per_image = total_duration / n_images |
| | else: |
| | seconds_per_image = duration_per_image if duration_per_image is not None else 4.0 |
| |
|
| | frames_per_image = max(1, int(round(seconds_per_image * fps))) |
| |
|
| | |
| | vm = vertical_movement or {"mode": "none"} |
| | vm_mode = vm.get("mode", "none") |
| | horizontal_distance = abs(end_yaw_deg - start_yaw_deg) |
| | degrees_per_frame = horizontal_distance / frames_per_image |
| |
|
| | |
| | total_frames = n_images * frames_per_image |
| |
|
| | |
| | if vm_mode == "separate" or vm_mode == "both": |
| | |
| | vertical_distance = 340.0 |
| | pole_frames = max(1, int(round(vertical_distance / degrees_per_frame))) |
| | total_frames += n_images * pole_frames |
| |
|
| | |
| | if speed_profile == "constant": |
| | progress_fn = _linear_progress |
| | elif speed_profile == "ease_in_out": |
| | progress_fn = _ease_in_out_progress |
| | else: |
| | raise ValueError("speed_profile must be 'constant' or 'ease_in_out'") |
| |
|
| | frame_idx = start_frame_index |
| | current_frame = 0 |
| | e2p_jit = e2p |
| |
|
| | yaw_start, yaw_end = start_yaw_deg, end_yaw_deg |
| |
|
| | for img_idx, e_img in enumerate(equi_tensors): |
| | if verbose: |
| | print(f"Processing image {img_idx + 1}/{n_images}...") |
| |
|
| | n = frames_per_image |
| | prog = progress_fn(n) |
| | yaw_values = [yaw_start + p * (yaw_end - yaw_start) for p in prog] |
| |
|
| | |
| | if vm_mode == "during" or vm_mode == "both": |
| | amplitude = float(vm.get("amplitude_deg", 15.0)) |
| | vertical_pattern = vm.get("pattern", "sine") |
| | if vertical_pattern == "sine": |
| | v_values = [amplitude * math.sin(2 * math.pi * p) for p in prog] |
| | else: |
| | v_values = [amplitude * (2 * p - 1) for p in prog] |
| | else: |
| | v_values = [0.0] * n |
| |
|
| | |
| | for i_frame in tqdm(range(n), desc=f"Image {img_idx + 1} rotation", disable=not verbose): |
| | h_deg = yaw_values[i_frame] |
| | v_deg = v_values[i_frame] |
| | pers = e2p_jit( |
| | e_img, |
| | fov_deg=fov_deg, |
| | h_deg=h_deg, |
| | v_deg=v_deg, |
| | out_hw=resolution, |
| | mode=interpolation_mode, |
| | channels_first=True, |
| | ).unsqueeze(0) |
| | filename = f"{filename_prefix}_{frame_idx:06d}.{save_format}" |
| | path = os.path.join(out_dir, filename) |
| | _save_tensor_as_image(pers, path) |
| | saved_paths.append(path) |
| | frame_idx += 1 |
| | current_frame += 1 |
| |
|
| | |
| | if vm_mode == "separate" or vm_mode == "both": |
| | if verbose: |
| | print(f" Generating pole sweep for image {img_idx + 1}...") |
| |
|
| | |
| | final_yaw = yaw_values[-1] |
| |
|
| | |
| | horizontal_distance = abs(yaw_end - yaw_start) |
| | degrees_per_frame = horizontal_distance / frames_per_image |
| |
|
| | |
| | vertical_distance = 340.0 |
| | pole_frames = max(1, int(round(vertical_distance / degrees_per_frame))) |
| |
|
| | if verbose: |
| | print(f" Horizontal: {horizontal_distance}° in {frames_per_image} frames ({degrees_per_frame:.2f}°/frame)") |
| | print(f" Vertical: {vertical_distance}° in {pole_frames} frames ({degrees_per_frame:.2f}°/frame)") |
| |
|
| | |
| | pole_progress = _linear_progress(pole_frames) |
| | pole_v_values = [] |
| |
|
| | |
| | total_distance = 340.0 |
| | phase1_distance = 85.0 |
| | phase2_distance = 170.0 |
| | phase3_distance = 85.0 |
| |
|
| | for p in pole_progress: |
| | current_distance = p * total_distance |
| |
|
| | if current_distance <= phase1_distance: |
| | |
| | phase_progress = current_distance / phase1_distance |
| | v_deg = 0.0 - (85.0 * phase_progress) |
| | elif current_distance <= phase1_distance + phase2_distance: |
| | |
| | phase_progress = (current_distance - phase1_distance) / phase2_distance |
| | v_deg = -85.0 + (170.0 * phase_progress) |
| | else: |
| | |
| | phase_progress = (current_distance - phase1_distance - phase2_distance) / phase3_distance |
| | v_deg = 85.0 - (85.0 * phase_progress) |
| |
|
| | pole_v_values.append(v_deg) |
| |
|
| | for pole_idx, v_deg in tqdm(enumerate(pole_v_values), total=len(pole_v_values), desc=f"Image {img_idx + 1} pole sweep", disable=not verbose): |
| | pers = e2p( |
| | e_img, |
| | fov_deg=fov_deg, |
| | h_deg=final_yaw, |
| | v_deg=v_deg, |
| | out_hw=resolution, |
| | mode=interpolation_mode, |
| | channels_first=True, |
| | ) |
| | filename = f"{filename_prefix}_{frame_idx:06d}.{save_format}" |
| | path = os.path.join(out_dir, filename) |
| | _save_tensor_as_image(pers, path) |
| | saved_paths.append(path) |
| | frame_idx += 1 |
| | current_frame += 1 |
| |
|
| | if verbose: |
| | print(f"\nCompleted! Generated {len(saved_paths)} frames in {out_dir}") |
| |
|
| | return saved_paths |
| |
|
| |
|
| | def main(): |
| | """ |
| | Main function - configure your parameters here |
| | """ |
| | |
| | IMAGE_PATHS = ["path/to/equi_image.jpg"] |
| | OUTPUT_DIR = "path/to/output_frames" |
| | start_idx = 0 |
| |
|
| | |
| | WIDTH = 1280 |
| | HEIGHT = 896 |
| | FPS = 60 |
| | DURATION_PER_IMAGE = 10.0 |
| | FOV_HORIZONTAL = 90.0169847156118 |
| | FOV_VERTICAL = 70 |
| |
|
| | |
| | SPEED_PROFILE = "constant" |
| | START_YAW = 0.0 |
| | END_YAW = 360.0 |
| |
|
| | |
| | VERTICAL_MOVEMENT = { |
| | "mode": "separate", |
| | "amplitude_deg": 90.0, |
| | "pattern": "sine", |
| | } |
| |
|
| | |
| | INTERPOLATION_MODE = "bilinear" |
| | SAVE_FORMAT = "png" |
| | FILENAME_PREFIX = "sweep360" |
| | DEVICE = "cuda:0" |
| |
|
| | |
| | equi_tensors = [] |
| | for img_path in IMAGE_PATHS: |
| | equi_tensors.append(load_image_to_tensor(img_path, DEVICE)) |
| |
|
| | if not equi_tensors: |
| | print("No images loaded. Please add your equirectangular images.") |
| | return |
| |
|
| | |
| | saved_paths = generate_frames_from_equirect( |
| | equi_tensors=equi_tensors, |
| | out_dir=OUTPUT_DIR, |
| | resolution=(HEIGHT, WIDTH), |
| | fps=FPS, |
| | duration_per_image=DURATION_PER_IMAGE, |
| | fov_deg=(FOV_HORIZONTAL, FOV_VERTICAL), |
| | interpolation_mode=INTERPOLATION_MODE, |
| | speed_profile=SPEED_PROFILE, |
| | vertical_movement=VERTICAL_MOVEMENT, |
| | start_yaw_deg=START_YAW, |
| | end_yaw_deg=END_YAW, |
| | save_format=SAVE_FORMAT, |
| | filename_prefix=FILENAME_PREFIX, |
| | verbose=True, |
| | start_frame_index=start_idx, |
| | ) |
| |
|
| | print(f"Successfully generated {len(saved_paths)} frames") |
| |
|
| |
|
| | if __name__ == "__main__": |
| | main() |
| |
|