| |
| """ |
| Image processing utilities for dataset loading. |
| Common functions shared across different datasets (AC-One, RobotWin, ALOHA, etc.) |
| """ |
|
|
| import numpy as np |
| import cv2 |
| import torch |
| from PIL import Image |
| from typing import Tuple, List |
| import random |
| from decord import VideoReader, cpu |
|
|
|
|
| def resize_with_padding(frame: np.ndarray, target_size: Tuple[int, int]) -> np.ndarray: |
| """ |
| Resize image with aspect ratio preservation and padding to target size. |
| |
| This function ensures no image distortion by: |
| 1. Calculating the minimum scale ratio to fit the image within target size |
| 2. Resizing the image with this ratio to preserve aspect ratio |
| 3. Padding with black borders to reach exact target size |
| 4. Centering the resized image within the padded frame |
| |
| Args: |
| frame: Input image [H, W, C] |
| target_size: Target size (height, width) |
| |
| Returns: |
| Processed image [target_height, target_width, C] |
| |
| Example: |
| >>> frame = np.random.randint(0, 255, (720, 640, 3), dtype=np.uint8) |
| >>> resized = resize_with_padding(frame, (384, 320)) |
| >>> print(resized.shape) # (384, 320, 3) |
| """ |
| target_height, target_width = target_size |
| original_height, original_width = frame.shape[:2] |
| |
| |
| scale_height = target_height / original_height |
| scale_width = target_width / original_width |
| scale = min(scale_height, scale_width) |
| |
| |
| new_height = int(original_height * scale) |
| new_width = int(original_width * scale) |
| |
| |
| resized_frame = cv2.resize(frame, (new_width, new_height)) |
| |
| |
| padded_frame = np.zeros((target_height, target_width, frame.shape[2]), dtype=frame.dtype) |
| |
| |
| y_offset = (target_height - new_height) // 2 |
| x_offset = (target_width - new_width) // 2 |
| |
| |
| padded_frame[y_offset:y_offset + new_height, x_offset:x_offset + new_width] = resized_frame |
| |
| return padded_frame |
|
|
|
|
| def load_video_frames(video_path: str, frame_indices: List[int], target_size: Tuple[int, int] = None) -> torch.Tensor: |
| """ |
| Load random frames from a video using decord, with optional aspect-ratio-preserving resize and padding. |
| |
| - Decoder: decord.VideoReader |
| - Access: get_batch(frame_indices) to fetch arbitrary frames in one call |
| - Color: decord returns frames in RGB with HWC layout |
| |
| Args: |
| video_path: Path to the video file. |
| frame_indices: Frame indices to read (can be unordered and may repeat). |
| target_size: Optional target size (height, width). If provided, resize with aspect ratio preserved and |
| center-pad with black borders to the exact target size. |
| |
| Returns: |
| torch.Tensor of shape [T, C, H, W] with values in [0, 1]. |
| """ |
|
|
| |
| vr = VideoReader(video_path, ctx=cpu(0), num_threads=4) |
| total_frames = len(vr) |
|
|
| if any(idx < 0 or idx >= total_frames for idx in frame_indices): |
| raise ValueError( |
| f"Some frame indices are out of bounds for video {video_path} (total frames: {total_frames})" |
| ) |
|
|
| |
| batch = vr.get_batch(frame_indices) |
| frames_np = batch.asnumpy() |
|
|
| |
| if target_size is not None: |
| th, tw = target_size |
| _, h, w, _ = frames_np.shape |
| if (h, w) != (th, tw): |
| |
| resized = [resize_with_padding(frames_np[i], target_size) for i in range(frames_np.shape[0])] |
| frames_np = np.stack(resized, axis=0) |
|
|
| |
| video_tensor = torch.from_numpy(frames_np).permute(0, 3, 1, 2).float() / 255.0 |
| return video_tensor |
|
|
|
|
| def load_first_frame(video_path: str, frame_idx: int, target_size: Tuple[int, int]) -> Tuple[torch.Tensor, torch.Tensor]: |
| """ |
| Load a single video frame in both resized and original resolution using decord only. |
| |
| Args: |
| video_path: Path to video file |
| frame_idx: Frame index to load |
| target_size: Target size (height, width) for resizing with padding |
| |
| Returns: |
| Tuple of (resized_frame, original_frame): |
| - resized_frame: Frame tensor [C, H, W] in range [0, 1] with padding applied |
| - original_frame: Frame tensor [C, H, W] in range [0, 1] with original resolution |
| """ |
| vr = VideoReader(video_path, ctx=cpu(0), num_threads=1) |
| total_frames = len(vr) |
| if frame_idx >= total_frames: |
| raise ValueError(f"Frame index {frame_idx} out of bounds for video {video_path} (total frames: {total_frames})") |
| frame_rgb = vr[frame_idx].asnumpy() |
| frame_original = torch.from_numpy(frame_rgb).permute(2, 0, 1).float() / 255.0 |
| frame_resized_np = resize_with_padding(frame_rgb, target_size) |
| frame_resized = torch.from_numpy(frame_resized_np).permute(2, 0, 1).float() / 255.0 |
| return frame_resized, frame_original |
|
|
|
|
| def get_video_frame_count(video_path: str) -> int: |
| """Get total frame count of a video using decord only.""" |
| vr = VideoReader(video_path, ctx=cpu(0), num_threads=1) |
| return len(vr) |
|
|
|
|
| def tensor_to_pil(tensor: torch.Tensor) -> Image.Image: |
| """ |
| Convert tensor [C, H, W] to PIL Image. |
| |
| Args: |
| tensor: Input tensor in format [C, H, W] |
| |
| Returns: |
| PIL Image in RGB mode |
| """ |
| |
| if tensor.shape[0] == 3: |
| image_np = tensor.permute(1, 2, 0).numpy() |
| |
| if image_np.max() <= 1.0: |
| image_np = (image_np * 255).astype(np.uint8) |
| else: |
| image_np = image_np.astype(np.uint8) |
| return Image.fromarray(image_np, mode='RGB') |
| else: |
| raise ValueError(f"Unsupported tensor shape: {tensor.shape}") |
|
|
|
|
| def apply_image_augmentation(frame: np.ndarray, |
| brightness_prob: float = 0.5, |
| brightness_range: Tuple[float, float] = (0.8, 1.2), |
| flip_prob: float = 0.3) -> np.ndarray: |
| """ |
| Apply common image augmentations to a frame. |
| |
| Args: |
| frame: Input image [H, W, C] |
| brightness_prob: Probability of applying brightness adjustment |
| brightness_range: Range of brightness factors (min, max) |
| flip_prob: Probability of applying horizontal flip |
| |
| Returns: |
| Augmented image [H, W, C] |
| """ |
| |
| if random.random() < brightness_prob: |
| brightness_factor = random.uniform(*brightness_range) |
| frame = np.clip(frame * brightness_factor, 0, 255) |
| |
| |
| if random.random() < flip_prob: |
| frame = np.fliplr(frame) |
| |
| return frame |
|
|
|
|
| |
| def test_resize_with_padding(): |
| """Test the resize_with_padding function with visual output.""" |
| import os |
| |
| print("=== Testing resize_with_padding ===") |
| |
| |
| test_frame = np.full((720, 640, 3), 255, dtype=np.uint8) |
| test_frame[100:200, 100:200] = [255, 0, 0] |
| test_frame[300:400, 300:400] = [0, 255, 0] |
| test_frame[500:600, 450:550] = [0, 0, 255] |
| test_frame[50:670, 50:70] = [255, 255, 0] |
| test_frame[50:670, 570:590] = [255, 0, 255] |
| test_frame[50:70, 50:590] = [0, 255, 255] |
| test_frame[650:670, 50:590] = [128, 128, 128] |
| |
| |
| target_size = (384, 320) |
| resized_frame = resize_with_padding(test_frame, target_size) |
| |
| print(f"Original image size: {test_frame.shape}") |
| print(f"Target size: {target_size}") |
| print(f"Resized image size: {resized_frame.shape}") |
| |
| |
| original_h, original_w = 720, 640 |
| target_h, target_w = 384, 320 |
| scale = min(target_h / original_h, target_w / original_w) |
| expected_new_h = int(original_h * scale) |
| expected_new_w = int(original_w * scale) |
| |
| print(f"Scaling factor: {scale}") |
| print(f"Expected scaled size: {expected_new_h}x{expected_new_w}") |
| print(f"Padding: top/bottom {(target_h - expected_new_h) // 2} pixels each") |
| print(f"Padding: left/right {(target_w - expected_new_w) // 2} pixels each") |
| |
| |
| y_offset = (target_h - expected_new_h) // 2 |
| x_offset = (target_w - expected_new_w) // 2 |
| |
| if y_offset > 0: |
| top_black = np.all(resized_frame[:y_offset, :, :] == 0) |
| bottom_black = np.all(resized_frame[y_offset + expected_new_h:, :, :] == 0) |
| print(f"Top padding check: {'β' if top_black else 'β'}") |
| print(f"Bottom padding check: {'β' if bottom_black else 'β'}") |
| |
| if x_offset > 0: |
| left_black = np.all(resized_frame[:, :x_offset, :] == 0) |
| right_black = np.all(resized_frame[:, x_offset + expected_new_w:, :] == 0) |
| print(f"Left padding check: {'β' if left_black else 'β'}") |
| print(f"Right padding check: {'β' if right_black else 'β'}") |
| |
| |
| try: |
| from PIL import Image |
| |
| |
| output_dir = "image_utils_test_output" |
| os.makedirs(output_dir, exist_ok=True) |
| |
| |
| original_pil = Image.fromarray(test_frame) |
| original_pil.save(os.path.join(output_dir, "original_720x640.png")) |
| print(f"β Saved original image: {output_dir}/original_720x640.png") |
| |
| |
| resized_pil = Image.fromarray(resized_frame) |
| resized_pil.save(os.path.join(output_dir, "resized_384x320.png")) |
| print(f"β Saved resized image: {output_dir}/resized_384x320.png") |
| |
| print("\n=== Test Complete ===") |
| print("β Aspect ratio preserved") |
| print("β No image distortion") |
| print("β Proper padding applied") |
| print("β Images saved successfully") |
| |
| except ImportError as e: |
| print(f"Could not save images due to missing dependencies: {e}") |
| except Exception as e: |
| print(f"Error saving images: {e}") |
|
|
|
|
| if __name__ == "__main__": |
| test_resize_with_padding() |