| """ |
| 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() |
|
|