Spaces:
Running on Zero
Running on Zero
Download model/loss.py from uva-cv-lab/wildrayzer: direct link, hf CLI and curl.
- Browser
- Download file 38.5 kB
-
https://huggingface.co/spaces/uva-cv-lab/wildrayzer/resolve/main/model/loss.py
- Command line
-
hf download hf://spaces/uva-cv-lab/wildrayzer/model/loss.py
-
curl -L -o loss.py https://huggingface.co/spaces/uva-cv-lab/wildrayzer/resolve/main/model/loss.py
38.5 kB
| # Copyright (c) 2025 Haian Jin. Created for the LVSM project (ICLR 2025). | |
| from einops import rearrange | |
| import lpips | |
| import numpy as np | |
| import torch.nn as nn | |
| import torch | |
| import torch.nn.functional as F | |
| from easydict import EasyDict as edict | |
| from torchvision.models import vgg19 | |
| import scipy.io | |
| import os | |
| from pathlib import Path | |
| # the perception loss code is modified from https://github.com/zhengqili/Crowdsampling-the-Plenoptic-Function/blob/f5216f312cf82d77f8d20454b5eeb3930324630a/models/networks.py#L1478 | |
| # and some parts are based on https://github.com/arthurhero/Long-LRM/blob/main/model/loss.py | |
| class PerceptualLoss(nn.Module): | |
| def __init__(self, device="cpu"): | |
| super().__init__() | |
| self.device = device | |
| self.vgg = self._build_vgg() | |
| self._load_weights() | |
| self._setup_feature_blocks() | |
| def _build_vgg(self): | |
| """Create VGG model with average pooling instead of max pooling.""" | |
| model = vgg19() | |
| # Replace max pooling with average pooling | |
| for i, layer in enumerate(model.features): | |
| if isinstance(layer, nn.MaxPool2d): | |
| model.features[i] = nn.AvgPool2d(kernel_size=2, stride=2) | |
| return model.to(self.device).eval() | |
| def _load_weights(self): | |
| """Load pre-trained VGG weights.""" | |
| weight_file = Path("./metric_checkpoint/imagenet-vgg-verydeep-19.mat") | |
| weight_file.parent.mkdir(exist_ok=True, parents=True) | |
| # Check if distributed training is initialized | |
| if torch.distributed.is_available() and torch.distributed.is_initialized(): | |
| if torch.distributed.get_rank() == 0: | |
| # Download weights if needed | |
| if not weight_file.exists(): | |
| os.system( | |
| f"wget https://www.vlfeat.org/matconvnet/models/imagenet-vgg-verydeep-19.mat -O {weight_file}" | |
| ) | |
| torch.distributed.barrier() | |
| else: | |
| # Single GPU mode - just download if needed | |
| if not weight_file.exists(): | |
| os.system( | |
| f"wget https://www.vlfeat.org/matconvnet/models/imagenet-vgg-verydeep-19.mat -O {weight_file}" | |
| ) | |
| # Load MatConvNet weights | |
| vgg_data = scipy.io.loadmat(weight_file) | |
| vgg_layers = vgg_data["layers"][0] | |
| # Layer indices and filter sizes | |
| layer_indices = [0, 2, 5, 7, 10, 12, 14, 16, 19, 21, 23, 25, 28, 30, 32, 34] | |
| filter_sizes = [ | |
| 64, | |
| 64, | |
| 128, | |
| 128, | |
| 256, | |
| 256, | |
| 256, | |
| 256, | |
| 512, | |
| 512, | |
| 512, | |
| 512, | |
| 512, | |
| 512, | |
| 512, | |
| 512, | |
| ] | |
| # Transfer weights to PyTorch model | |
| with torch.no_grad(): | |
| for i, layer_idx in enumerate(layer_indices): | |
| # Set weights | |
| weights = torch.from_numpy(vgg_layers[layer_idx][0][0][2][0][0]).permute(3, 2, 0, 1) | |
| self.vgg.features[layer_idx].weight = nn.Parameter(weights, requires_grad=False) | |
| # Set biases | |
| biases = torch.from_numpy(vgg_layers[layer_idx][0][0][2][0][1]).view( | |
| filter_sizes[i] | |
| ) | |
| self.vgg.features[layer_idx].bias = nn.Parameter(biases, requires_grad=False) | |
| def _setup_feature_blocks(self): | |
| """Create feature extraction blocks at different network depths.""" | |
| output_indices = [0, 4, 9, 14, 23, 32] | |
| self.blocks = nn.ModuleList() | |
| # Create sequential blocks | |
| for i in range(len(output_indices) - 1): | |
| block = nn.Sequential( | |
| *list(self.vgg.features[output_indices[i] : output_indices[i + 1]]) | |
| ) | |
| self.blocks.append(block.to(self.device).eval()) | |
| # Freeze all parameters | |
| for param in self.vgg.parameters(): | |
| param.requires_grad = False | |
| def _extract_features(self, x): | |
| """Extract features from each block.""" | |
| features = [] | |
| for block in self.blocks: | |
| x = block(x) | |
| features.append(x) | |
| return features | |
| def _preprocess_images(self, images): | |
| """Convert images to VGG input format.""" | |
| # VGG mean values for ImageNet | |
| mean = torch.tensor([123.6800, 116.7790, 103.9390]).reshape(1, 3, 1, 1).to(images.device) | |
| return images * 255.0 - mean | |
| def _compute_error(real, fake): | |
| return torch.mean(torch.abs(real - fake)) | |
| def _compute_error_with_mask(real, fake, mask): | |
| return torch.mean(torch.abs(real - fake) * mask) | |
| def forward(self, pred_img, target_img, mask=None): | |
| """Compute perceptual loss between prediction and target.""" | |
| # Preprocess images | |
| target_img_p = self._preprocess_images(target_img) | |
| pred_img_p = self._preprocess_images(pred_img) | |
| # Extract features | |
| target_features = self._extract_features(target_img_p) | |
| pred_features = self._extract_features(pred_img_p) | |
| # Pixel-level error | |
| if mask is not None: | |
| def m_at(feat): | |
| if mask.shape[-2:] != feat.shape[-2:]: | |
| # Use nearest to preserve binary mask (avoid soft edges from bilinear) | |
| return F.interpolate(mask, size=feat.shape[-2:], mode="nearest") | |
| return mask | |
| e0 = self._compute_error_with_mask(target_img_p, pred_img_p, m_at(target_img_p)) | |
| e1 = ( | |
| self._compute_error_with_mask( | |
| target_features[0], pred_features[0], m_at(target_features[0]) | |
| ) | |
| / 2.6 | |
| ) | |
| e2 = ( | |
| self._compute_error_with_mask( | |
| target_features[1], pred_features[1], m_at(target_features[1]) | |
| ) | |
| / 4.8 | |
| ) | |
| e3 = ( | |
| self._compute_error_with_mask( | |
| target_features[2], pred_features[2], m_at(target_features[2]) | |
| ) | |
| / 3.7 | |
| ) | |
| e4 = ( | |
| self._compute_error_with_mask( | |
| target_features[3], pred_features[3], m_at(target_features[3]) | |
| ) | |
| / 5.6 | |
| ) | |
| e5 = ( | |
| self._compute_error_with_mask( | |
| target_features[4], pred_features[4], m_at(target_features[4]) | |
| ) | |
| / 1.5 | |
| * 10 | |
| ) | |
| else: | |
| e0 = self._compute_error(target_img_p, pred_img_p) | |
| e1 = self._compute_error(target_features[0], pred_features[0]) / 2.6 | |
| e2 = self._compute_error(target_features[1], pred_features[1]) / 4.8 | |
| e3 = self._compute_error(target_features[2], pred_features[2]) / 3.7 | |
| e4 = self._compute_error(target_features[3], pred_features[3]) / 5.6 | |
| e5 = self._compute_error(target_features[4], pred_features[4]) * 10 / 1.5 | |
| # Feature-level errors with scaling factors | |
| # Combine all errors and normalize | |
| total_loss = (e0 + e1 + e2 + e3 + e4 + e5) / 255.0 | |
| return total_loss | |
| class LossComputer(nn.Module): | |
| def __init__(self, config): | |
| super().__init__() | |
| self.config = config | |
| # PSNR filtering: skip samples with PSNR below threshold | |
| self.min_psnr_for_loss = self.config.training.get("min_psnr_for_loss", 0.0) | |
| if self.min_psnr_for_loss > 0: | |
| print( | |
| f"[Loss] PSNR filtering enabled: Skip samples with PSNR < {self.min_psnr_for_loss} dB" | |
| ) | |
| if self.config.training.lpips_loss_weight > 0.0: | |
| # avoid multiple GPUs from downloading the same LPIPS model multiple times | |
| if torch.distributed.get_rank() == 0: | |
| self.lpips_loss_module = self._init_frozen_module(lpips.LPIPS(net="vgg")) | |
| torch.distributed.barrier() | |
| if torch.distributed.get_rank() != 0: | |
| self.lpips_loss_module = self._init_frozen_module(lpips.LPIPS(net="vgg")) | |
| if self.config.training.perceptual_loss_weight > 0.0: | |
| self.perceptual_loss_module = self._init_frozen_module(PerceptualLoss()) | |
| def _init_frozen_module(self, module): | |
| """Helper method to initialize and freeze a module's parameters.""" | |
| module.eval() | |
| for param in module.parameters(): | |
| param.requires_grad = False | |
| return module | |
| def forward( | |
| self, | |
| rendering, | |
| target, | |
| input=None, | |
| create_visual=False, | |
| motion_mask=None, | |
| predicted_masks_all=None, | |
| pseudolabels_all=None, | |
| current_step=0, | |
| dataset_sources=None, | |
| ): | |
| """ | |
| Calculate rendering losses and optional motion mask distillation loss. | |
| Args: | |
| rendering: [b, v, 3, h, w], value range [0, 1] | |
| target: [b, v, 3, h, w], value range [0, 1] | |
| input: unused, kept for compatibility | |
| create_visual: unused, kept for compatibility | |
| motion_mask: unused (rendering losses are disabled during motion-mask-only training) | |
| predicted_masks_all: [b*v, 1, H, W] predicted motion mask logits (for distillation) | |
| pseudolabels_all: [b*v, 1, H, W] DINOv3 pseudo-label masks (for distillation) | |
| current_step: unused, kept for future extensions | |
| dataset_sources: list of dataset sources (e.g., ["re10k", "dre10k", ...]) for per-sample handling | |
| Returns: | |
| Dictionary of loss metrics | |
| """ | |
| b, v, _, h, w = rendering.size() | |
| device = rendering.device | |
| # PSNR filtering: skip samples with catastrophically bad renderings (except RE10K) | |
| if self.min_psnr_for_loss > 0: | |
| rendering_flat = rendering.reshape(b * v, -1, h, w) | |
| target_flat = target.reshape(b * v, -1, h, w) | |
| # Compute PSNR per sample | |
| with torch.no_grad(): | |
| mse_per_sample = ((rendering_flat - target_flat) ** 2).mean(dim=[1, 2, 3]) # [b*v] | |
| psnr_per_sample = -10.0 * torch.log10(mse_per_sample.clamp(min=1e-10)) | |
| # Create mask for RE10K samples (always include in loss) | |
| if dataset_sources is not None: | |
| is_re10k = torch.tensor( | |
| [dataset_sources[i // v] == "re10k" for i in range(b * v)], | |
| device=device, | |
| dtype=torch.float32, | |
| ) # [b*v] | |
| else: | |
| is_re10k = torch.zeros(b * v, device=device, dtype=torch.float32) | |
| # # Visualization: Save some samples to debug poor rendering quality | |
| # if not hasattr(self, '_viz_saved'): | |
| # self._viz_saved = True | |
| # import os | |
| # from PIL import Image | |
| # import numpy as np | |
| # viz_dir = "./experiments/evaluation" | |
| # os.makedirs(viz_dir, exist_ok=True) | |
| # # Save first 8 samples (or fewer if batch is smaller) | |
| # num_viz = min(8, rendering_flat.shape[0]) | |
| # for i in range(num_viz): | |
| # # Get rendering and target for this sample | |
| # render_img = rendering_flat[i].cpu().float().numpy() # [3, h, w] | |
| # target_img = target_flat[i].cpu().float().numpy() # [3, h, w] | |
| # psnr_val = psnr_per_sample[i].item() | |
| # # Convert to [h, w, 3] and clip to [0, 1] | |
| # render_img = np.transpose(render_img, (1, 2, 0)).clip(0, 1) | |
| # target_img = np.transpose(target_img, (1, 2, 0)).clip(0, 1) | |
| # # Convert to uint8 | |
| # render_img = (render_img * 255).astype(np.uint8) | |
| # target_img = (target_img * 255).astype(np.uint8) | |
| # # Create side-by-side comparison | |
| # h, w = render_img.shape[:2] | |
| # comparison = np.zeros((h, w * 2, 3), dtype=np.uint8) | |
| # comparison[:, :w] = target_img | |
| # comparison[:, w:] = render_img | |
| # # Save | |
| # img_path = os.path.join(viz_dir, f"debug_sample_{i:02d}_psnr_{psnr_val:.2f}.png") | |
| # Image.fromarray(comparison).save(img_path) | |
| # print(f"[DEBUG] Saved comparison to {img_path} (PSNR: {psnr_val:.2f} dB)") | |
| # print(f"[DEBUG] PSNR stats: min={psnr_per_sample.min().item():.2f}, " | |
| # f"max={psnr_per_sample.max().item():.2f}, " | |
| # f"mean={psnr_per_sample.mean().item():.2f} dB") | |
| # Filter: keep samples with PSNR >= threshold OR RE10K samples (always keep) | |
| psnr_valid = (psnr_per_sample >= self.min_psnr_for_loss).float() # [b*v] | |
| valid_mask = torch.clamp( | |
| psnr_valid + is_re10k, 0.0, 1.0 | |
| ) # Union: PSNR valid OR RE10K | |
| num_valid = int(valid_mask.sum().item()) | |
| # If all samples are invalid, return zero loss (skip this batch) | |
| # Create a leaf tensor with requires_grad for proper gradient flow | |
| if num_valid == 0: | |
| zero_loss = torch.zeros(1, device=device, requires_grad=True).squeeze() | |
| return edict( | |
| loss=zero_loss, | |
| l2_loss=torch.tensor(0.0, device=device), | |
| psnr=torch.tensor(0.0, device=device), | |
| lpips_loss=torch.tensor(0.0, device=device), | |
| perceptual_loss=torch.tensor(0.0, device=device), | |
| psnr_filtered_samples=torch.tensor(0, device=device), | |
| psnr_total_samples=torch.tensor(b * v, device=device), | |
| ) | |
| # Reshape valid_mask for indexing: [b*v] -> [b, v] | |
| valid_mask_bv = valid_mask.reshape(b, v) | |
| # Gather valid samples | |
| valid_indices = torch.nonzero(valid_mask_bv, as_tuple=False) # [num_valid, 2] | |
| rendering = rendering[valid_indices[:, 0], valid_indices[:, 1]].unsqueeze( | |
| 0 | |
| ) # [1, num_valid, 3, h, w] | |
| target = target[valid_indices[:, 0], valid_indices[:, 1]].unsqueeze(0) | |
| # Also filter predicted_masks_all and pseudolabels_all if provided | |
| if predicted_masks_all is not None: | |
| # valid_mask: [b*v] -> filter [b*v, 1, H, W] | |
| predicted_masks_all = predicted_masks_all[valid_mask.bool()] # [num_valid, 1, H, W] | |
| if pseudolabels_all is not None: | |
| pseudolabels_all = pseudolabels_all[valid_mask.bool()] | |
| # Update batch size for subsequent calculations | |
| b, v = 1, num_valid | |
| else: | |
| num_valid = b * v | |
| # Compute rendering losses (will be 0 if weights are 0) | |
| rendering_flat = rendering.reshape(b * v, -1, h, w) | |
| target_flat = target.reshape(b * v, -1, h, w) | |
| # Handle alpha channel if present | |
| if target_flat.size(1) == 4: | |
| target_flat, _ = target_flat.split([3, 1], dim=1) | |
| # Create binary static mask from predicted motion masks (1=static, 0=motion) | |
| static_mask = None | |
| if predicted_masks_all is not None: | |
| threshold = self.config.model.get("motion_mask_threshold", 0.1) | |
| motion_prob = torch.sigmoid(predicted_masks_all) # [b*v, 1, H, W] | |
| static_mask = (motion_prob <= threshold).float() # Inverse: 1=static, 0=motion | |
| # Upsample to match rendering resolution if needed | |
| assert static_mask.shape[-2:] == (h, w) | |
| l2_loss = torch.tensor(1e-8).to(device) | |
| if self.config.training.l2_loss_weight > 0.0: | |
| if static_mask is not None: | |
| # Masked MSE: only compute on static regions | |
| diff_sq = (rendering_flat - target_flat) ** 2 * static_mask | |
| l2_loss = diff_sq.sum() / static_mask.sum().clamp(min=1.0) | |
| else: | |
| l2_loss = F.mse_loss(rendering_flat, target_flat) | |
| psnr = -10.0 * torch.log10(l2_loss.clamp(min=1e-10)) | |
| lpips_loss = torch.tensor(0.0).to(device) | |
| if self.config.training.lpips_loss_weight > 0.0: | |
| # Scale from [0,1] to [-1,1] as required by LPIPS | |
| lpips_loss = self.lpips_loss_module( | |
| rendering_flat * 2.0 - 1.0, target_flat * 2.0 - 1.0 | |
| ).mean() | |
| perceptual_loss = torch.tensor(0.0).to(device) | |
| if self.config.training.perceptual_loss_weight > 0.0: | |
| perceptual_loss = self.perceptual_loss_module( | |
| rendering_flat, target_flat, mask=static_mask | |
| ) | |
| loss = ( | |
| self.config.training.l2_loss_weight * l2_loss | |
| + self.config.training.lpips_loss_weight * lpips_loss | |
| + self.config.training.perceptual_loss_weight * perceptual_loss | |
| ) | |
| loss_metrics = edict( | |
| loss=loss, | |
| l2_loss=l2_loss, | |
| psnr=psnr, | |
| lpips_loss=lpips_loss, | |
| perceptual_loss=perceptual_loss, | |
| norm_perceptual_loss=perceptual_loss / l2_loss.clamp(min=1e-10), | |
| norm_lpips_loss=lpips_loss / l2_loss.clamp(min=1e-10), | |
| ) | |
| # Add PSNR filtering stats if enabled | |
| if self.min_psnr_for_loss > 0: | |
| loss_metrics.psnr_filtered_samples = torch.tensor(num_valid, device=device) | |
| loss_metrics.psnr_total_samples = torch.tensor( | |
| rendering.shape[0] * rendering.shape[1], device=device | |
| ) # original b*v | |
| # Motion mask distillation loss (simple BCE) | |
| if predicted_masks_all is not None and pseudolabels_all is not None: | |
| # Upsample predicted masks to match pseudo-label resolution | |
| # predicted_masks_all: [num_valid, 1, H_pred, W_pred] -> [num_valid, 1, H_target, W_target] | |
| target_size = pseudolabels_all.shape[-2:] # e.g., (256, 256) | |
| if predicted_masks_all.shape[-2:] != target_size: | |
| predicted_masks_upsampled = F.interpolate( | |
| predicted_masks_all, size=target_size, mode="bilinear", align_corners=False | |
| ) | |
| else: | |
| predicted_masks_upsampled = predicted_masks_all | |
| # BCE loss between predicted logits and DINOv3 pseudo-labels | |
| mask_bce_loss = F.binary_cross_entropy_with_logits( | |
| predicted_masks_upsampled, pseudolabels_all | |
| ) | |
| # Get distillation weight from config | |
| mask_distill_weight = self.config.training.get("mask_distill_loss_weight", 1.0) | |
| # Add to total loss | |
| loss_metrics.loss = loss_metrics.loss + mask_distill_weight * mask_bce_loss | |
| loss_metrics.mask_distill_loss = mask_bce_loss | |
| loss_metrics.mask_distill_weight = torch.tensor(mask_distill_weight, device=device) | |
| return loss_metrics | |
| # ============================================================================== | |
| # Loss Computer for Official RAYZAR Model | |
| # ============================================================================== | |
| class LossComputer_official(nn.Module): | |
| """ | |
| Loss computer for the official RAYZAR model (Images2Latent4D). | |
| Supports L2, LPIPS, Perceptual, and SSIM losses. | |
| """ | |
| def __init__(self, config): | |
| super().__init__() | |
| self.config = config | |
| # loss weight for input views | |
| self.weight_l2 = self.config.training.get("l2_loss_weight", 1.0) | |
| self.weight_lpips = self.config.training.get("lpips_loss_weight", 0.0) | |
| self.weight_perceptual = self.config.training.get("perceptual_loss_weight", 0.0) | |
| self.weight_ssim = self.config.training.get("ssim_loss_weight", 0.0) | |
| self.weight_clip = self.config.training.get("clip_loss_weight", 0.0) | |
| # Motion mask distillation weight | |
| self.weight_mask_distill = self.config.training.get("mask_distill_loss_weight", 0.0) | |
| # PSNR filtering: skip samples with PSNR below threshold for mask distillation | |
| self.psnr_filter_threshold = self.config.training.get("psnr_filter_threshold", 0.0) | |
| # Use predicted motion masks to mask out dynamic regions from reconstruction loss | |
| self.use_masked_reconstruction_loss = self.config.training.get( | |
| "use_masked_reconstruction_loss", False | |
| ) | |
| # Motion mask threshold for reconstruction loss masking | |
| self.motion_mask_threshold = self.config.training.get("motion_mask_threshold", 0.1) | |
| # loss weight for interpolation views | |
| self.weight_l2_interpolate = self.config.training.get("l2_loss_weight_interpolate", 0.0) | |
| self.weight_lpips_interpolate = self.config.training.get( | |
| "lpips_loss_weight_interpolate", 0.0 | |
| ) | |
| self.weight_perceptual_interpolate = self.config.training.get( | |
| "perceptual_loss_weight_interpolate", 0.0 | |
| ) | |
| self.weight_ssim_interpolate = self.config.training.get("ssim_loss_weight_interpolate", 0.0) | |
| self.weight_clip_interpolate = self.config.training.get("clip_loss_weight_interpolate", 0.0) | |
| # loss weight for code swapping (contrastive loss) | |
| self.weight_contrastive = self.config.training.get("contrastive_loss_swapping_weight", 0.0) | |
| if self.weight_lpips > 0.0 or self.weight_lpips_interpolate > 0.0: | |
| self.lpips_loss_module = lpips.LPIPS(net="vgg") | |
| self.lpips_loss_module.eval() | |
| # freeze the lpips loss module | |
| for param in self.lpips_loss_module.parameters(): | |
| param.requires_grad = False | |
| if self.weight_perceptual > 0.0 or self.weight_perceptual_interpolate > 0.0: | |
| self.perceptual_loss_module = PerceptualLoss() | |
| self.perceptual_loss_module.eval() | |
| # freeze the perceptual loss module | |
| for param in self.perceptual_loss_module.parameters(): | |
| param.requires_grad = False | |
| self.supervise_interpolate = config.training.get("supervise_interpolate", False) | |
| self.render_interpolate = config.training.get("render_interpolate", False) | |
| if self.supervise_interpolate: | |
| assert self.render_interpolate, "Must render interpolated images for supervising them" | |
| def calculate_loss( | |
| self, rendering, target, create_visual=True, is_interpolate=False, static_mask=None | |
| ): | |
| """ | |
| rendering: [b, v, 3, h, w]; in range (0, 1) | |
| target: [b, v, 3, h, w]; in range (0, 1) | |
| static_mask: [b*v, 1, h, w]; binary mask where 1=static (compute loss), 0=dynamic (ignore) | |
| """ | |
| b, v, _, h, w = rendering.size() | |
| rendering = rendering.reshape(b * v, -1, h, w) # [b*v,c,h,w] | |
| target = target.reshape(b * v, -1, h, w) | |
| if is_interpolate: | |
| weight_l2 = self.weight_l2_interpolate | |
| weight_lpips = self.weight_lpips_interpolate | |
| weight_perceptual = self.weight_perceptual_interpolate | |
| weight_ssim = self.weight_ssim_interpolate | |
| weight_clip = self.weight_clip_interpolate | |
| else: | |
| weight_l2 = self.weight_l2 | |
| weight_lpips = self.weight_lpips | |
| weight_perceptual = self.weight_perceptual | |
| weight_ssim = self.weight_ssim | |
| weight_clip = self.weight_clip | |
| mask = None | |
| if target.size(1) == 4: | |
| target, mask = target.split([3, 1], dim=1) | |
| # Compute masked or unmasked L2 loss | |
| l2_loss = torch.tensor(1e-8).to(rendering.device) | |
| with torch.set_grad_enabled(weight_l2 > 0.0): | |
| if static_mask is not None: | |
| # Upsample mask to match rendering resolution if needed (use nearest to preserve binary) | |
| if static_mask.shape[-2:] != (h, w): | |
| static_mask_resized = F.interpolate(static_mask, size=(h, w), mode="nearest") | |
| else: | |
| static_mask_resized = static_mask | |
| # Check if we have enough static pixels (avoid divide-by-tiny instability) | |
| num_static_pixels = static_mask_resized.sum() | |
| min_static_pixels = ( | |
| 0.1 * b * v * h * w | |
| ) # At least 10% of total pixels should be static | |
| if num_static_pixels < min_static_pixels: | |
| # Too few static pixels - fall back to unmasked loss to avoid instability | |
| l2_loss = F.mse_loss(rendering, target) | |
| else: | |
| # Masked L2 loss: only compute on static regions | |
| mse_per_pixel = (rendering - target) ** 2 # [b*v, 3, h, w] | |
| masked_mse = mse_per_pixel * static_mask_resized # [b*v, 3, h, w] | |
| l2_loss = masked_mse.sum() / (num_static_pixels * rendering.size(1)) | |
| else: | |
| l2_loss = F.mse_loss(rendering, target) | |
| psnr = -10.0 * torch.log10(l2_loss) | |
| lpips_loss = torch.tensor(0.0).to(l2_loss.device) | |
| if weight_lpips > 0.0: | |
| # Note: LPIPS doesn't have native mask support, so we apply mask after | |
| if static_mask is not None: | |
| if static_mask.shape[-2:] != (h, w): | |
| static_mask_resized = F.interpolate(static_mask, size=(h, w), mode="nearest") | |
| else: | |
| static_mask_resized = static_mask | |
| # Check if we have enough static pixels | |
| num_static_pixels = static_mask_resized.sum() | |
| min_static_pixels = 0.1 * b * v * h * w | |
| if num_static_pixels < min_static_pixels: | |
| # Too few static pixels - fall back to unmasked loss | |
| lpips_loss = self.lpips_loss_module( | |
| rendering * 2.0 - 1.0, target * 2.0 - 1.0 | |
| ).mean() | |
| else: | |
| # Compute LPIPS per-pixel, then mask | |
| lpips_per_pixel = self.lpips_loss_module( | |
| rendering * 2.0 - 1.0, target * 2.0 - 1.0, normalize=False | |
| ) # [b*v, 1, h', w'] | |
| # Upsample mask to match LPIPS output resolution (use nearest to preserve binary) | |
| if static_mask_resized.shape[-2:] != lpips_per_pixel.shape[-2:]: | |
| static_mask_lpips = F.interpolate( | |
| static_mask_resized, size=lpips_per_pixel.shape[-2:], mode="nearest" | |
| ) | |
| else: | |
| static_mask_lpips = static_mask_resized | |
| num_static_lpips = static_mask_lpips.sum() | |
| masked_lpips = lpips_per_pixel * static_mask_lpips | |
| lpips_loss = masked_lpips.sum() / num_static_lpips.clamp(min=1.0) | |
| else: | |
| lpips_loss = self.lpips_loss_module( | |
| rendering * 2.0 - 1.0, target * 2.0 - 1.0 | |
| ).mean() | |
| perceptual_loss = torch.tensor(0.0).to(l2_loss.device) | |
| if weight_perceptual > 0.0: | |
| # PerceptualLoss already supports masking | |
| if static_mask is not None: | |
| if static_mask.shape[-2:] != (h, w): | |
| static_mask_resized = F.interpolate(static_mask, size=(h, w), mode="nearest") | |
| else: | |
| static_mask_resized = static_mask | |
| # Check if we have enough static pixels | |
| num_static_pixels = static_mask_resized.sum() | |
| min_static_pixels = 0.1 * b * v * h * w | |
| if num_static_pixels < min_static_pixels: | |
| # Too few static pixels - fall back to unmasked loss | |
| perceptual_loss = self.perceptual_loss_module(rendering, target) | |
| else: | |
| perceptual_loss = self.perceptual_loss_module( | |
| rendering, target, mask=static_mask_resized | |
| ) | |
| else: | |
| perceptual_loss = self.perceptual_loss_module(rendering, target) | |
| ssim_loss = torch.tensor(0.0).to(l2_loss.device) | |
| if weight_ssim > 0.0: | |
| # Note: SSIM doesn't have native mask support in this implementation | |
| # For now, compute without mask (could implement masked SSIM if needed) | |
| ssim_loss = self.ssim_loss_module(rendering, target) | |
| loss = ( | |
| weight_l2 * l2_loss | |
| + weight_lpips * lpips_loss | |
| + weight_perceptual * perceptual_loss | |
| + weight_ssim * ssim_loss | |
| ) | |
| visual = None | |
| if create_visual: | |
| visual = torch.cat((target, rendering), dim=3).detach().cpu() # [b*v, c, h, w * 2] | |
| visual = rearrange(visual, "(b v) c h (m w) -> (b h) (v m w) c", v=v, m=2) | |
| visual = (visual.numpy() * 255.0).clip(0.0, 255.0).astype(np.uint8) | |
| loss_metrics = edict( | |
| loss=loss, | |
| l2_loss=l2_loss, | |
| psnr=psnr, | |
| lpips_loss=lpips_loss, | |
| perceptual_loss=perceptual_loss, | |
| ssim_loss=ssim_loss, | |
| visual=visual, | |
| ) | |
| return loss_metrics | |
| def forward( | |
| self, | |
| rendering, | |
| target, | |
| input, | |
| predicted_target_masks=None, | |
| dinov3_target_masks=None, | |
| predicted_input_masks=None, | |
| copy_paste_input_masks=None, | |
| create_visual=False, | |
| dataset_sources=None, | |
| ): | |
| """ | |
| rendering: [b, v, 3, h, w]; in range (0, 1) | |
| target: [b, v, 3, h, w]; in range (0, 1) | |
| predicted_target_masks: [b*v, 1, H, W] predicted motion mask logits (optional) | |
| dinov3_target_masks: [b*v, 1, H, W] DINOv3 pseudo-label masks (optional) | |
| predicted_input_masks: [b*v_input, 1, H, W] predicted motion mask logits for input views | |
| copy_paste_input_masks: [b*v_input, 1, H, W] binary GT masks for input views (copy-paste) | |
| dataset_sources: list of dataset sources (e.g., ["re10k", "dre10k", ...]) for per-sample handling | |
| """ | |
| # Create static mask from predicted motion masks (invert so 1=static, 0=dynamic) | |
| # IMPORTANT: Only apply masking to DRE10K (dynamic scenes), not RE10K (static scenes) | |
| static_mask = None | |
| if self.use_masked_reconstruction_loss and predicted_target_masks is not None: | |
| b, v = rendering.shape[0], rendering.shape[1] | |
| # 1) Convert logits -> prob -> hard binary motion -> invert to static | |
| motion_mask_prob = torch.sigmoid(predicted_target_masks) # [b*v, 1, H, W] | |
| # Threshold to get binary mask (>threshold means motion) | |
| motion_mask_binary = ( | |
| motion_mask_prob > self.motion_mask_threshold | |
| ).float() # [b*v, 1, H, W] | |
| # Invert to get static mask (1=static, 0=dynamic) | |
| # CRITICAL: Detach to prevent gradient flow to mask predictor (frozen in Stage 3) | |
| static_mask_raw = (1.0 - motion_mask_binary).detach() # [b*v, 1, H, W] | |
| # Only apply masking to DRE10K samples (we KNOW RE10K is static) | |
| if dataset_sources is not None: | |
| # Safety check: ensure dataset_sources matches batch size | |
| assert ( | |
| len(dataset_sources) == b | |
| ), f"dataset_sources length {len(dataset_sources)} != batch size {b}" | |
| # 2) Normalize and allow variants like 'dre10k_aug', 'dynamic' | |
| def is_dre10k(tag): | |
| """Check if dataset is dynamic (DRE10K or variants).""" | |
| t = str(tag).lower() | |
| return ("dre10k" in t) or (t == "dynamic") | |
| # Create per-sample mask enable flag: True for DRE10K, False for RE10K | |
| apply_mask = torch.tensor( | |
| [is_dre10k(ds) for ds in dataset_sources], | |
| device=rendering.device, | |
| dtype=torch.bool, # Use bool dtype for semantic clarity | |
| ) # [b] | |
| # Expand to per-view: [b] -> [b*v, 1, 1, 1] | |
| apply_mask_expanded = apply_mask.repeat_interleave(v).view(-1, 1, 1, 1) | |
| # For RE10K: use all 1.0 (full image), for DRE10K: use predicted mask | |
| static_mask = torch.where( | |
| apply_mask_expanded, | |
| static_mask_raw, # DRE10K: use predicted mask | |
| torch.ones_like(static_mask_raw), # RE10K: all static (1.0 = full image) | |
| ) | |
| else: | |
| # Fallback: apply to all samples (backward compatibility) | |
| static_mask = static_mask_raw | |
| # calculate loss for input views with static mask | |
| loss_metrics_input = self.calculate_loss( | |
| rendering, | |
| target, | |
| create_visual=create_visual, | |
| is_interpolate=False, | |
| static_mask=static_mask, | |
| ) | |
| # Motion mask distillation loss (BCE between predicted masks and DINOv3 pseudo-labels) | |
| mask_bce_loss = torch.tensor(0.0, device=rendering.device) | |
| input_mask_bce_loss = torch.tensor(0.0, device=rendering.device) | |
| if ( | |
| self.weight_mask_distill > 0.0 | |
| and predicted_input_masks is not None | |
| and copy_paste_input_masks is not None | |
| ): | |
| target_size_input = copy_paste_input_masks.shape[-2:] | |
| if predicted_input_masks.shape[-2:] != target_size_input: | |
| predicted_input_masks_upsampled = F.interpolate( | |
| predicted_input_masks, | |
| size=target_size_input, | |
| mode="bilinear", | |
| align_corners=False, | |
| ) | |
| else: | |
| predicted_input_masks_upsampled = predicted_input_masks | |
| input_mask_bce_loss = F.binary_cross_entropy_with_logits( | |
| predicted_input_masks_upsampled, | |
| copy_paste_input_masks.to( | |
| predicted_input_masks_upsampled.device, dtype=torch.float32 | |
| ), | |
| ) | |
| if ( | |
| self.weight_mask_distill > 0.0 | |
| and predicted_target_masks is not None | |
| and dinov3_target_masks is not None | |
| ): | |
| # Apply PSNR filtering: only use samples with good rendering quality | |
| # IMPORTANT: Use the SAME masked MSE as the reconstruction loss for consistency | |
| if self.psnr_filter_threshold > 0.0: | |
| # Compute per-view PSNR using the SAME static mask as reconstruction loss | |
| b, v, _, h, w = rendering.size() | |
| rendering_flat = rendering.reshape(b * v, -1, h, w) | |
| target_flat = target.reshape(b * v, -1, h, w) | |
| if static_mask is not None: | |
| # Upsample mask to rendering resolution (use nearest to preserve binary) | |
| if static_mask.shape[-2:] != (h, w): | |
| static_mask_resized = F.interpolate( | |
| static_mask, size=(h, w), mode="nearest" | |
| ) | |
| else: | |
| static_mask_resized = static_mask | |
| # Compute masked MSE per view (same as L2 loss) | |
| mse_per_pixel = (rendering_flat - target_flat) ** 2 # [b*v, 3, h, w] | |
| masked_mse = mse_per_pixel * static_mask_resized # [b*v, 3, h, w] | |
| # Average over pixels and channels for each view | |
| mse_per_view = masked_mse.sum(dim=[1, 2, 3]) / ( | |
| static_mask_resized.sum(dim=[1, 2, 3]) * rendering_flat.size(1) + 1e-8 | |
| ) # [b*v] | |
| else: | |
| # No mask - compute regular MSE | |
| mse_per_view = F.mse_loss(rendering_flat, target_flat, reduction="none") | |
| mse_per_view = mse_per_view.mean(dim=[1, 2, 3]) # [b*v] | |
| psnr_per_view = -10.0 * torch.log10(mse_per_view.clamp(min=1e-10)) | |
| # Only keep samples with PSNR above threshold | |
| valid_mask = psnr_per_view >= self.psnr_filter_threshold # [b*v] | |
| if valid_mask.sum() > 0: | |
| predicted_masks_filtered = predicted_target_masks[valid_mask] | |
| dinov3_masks_filtered = dinov3_target_masks[valid_mask] | |
| else: | |
| # No valid samples, skip mask loss | |
| predicted_masks_filtered = None | |
| dinov3_masks_filtered = None | |
| else: | |
| predicted_masks_filtered = predicted_target_masks | |
| dinov3_masks_filtered = dinov3_target_masks | |
| # Compute BCE loss if we have valid samples | |
| if predicted_masks_filtered is not None and dinov3_masks_filtered is not None: | |
| # Upsample predicted masks to match pseudo-label resolution if needed | |
| target_size = dinov3_masks_filtered.shape[-2:] | |
| if predicted_masks_filtered.shape[-2:] != target_size: | |
| predicted_masks_upsampled = F.interpolate( | |
| predicted_masks_filtered, | |
| size=target_size, | |
| mode="bilinear", | |
| align_corners=False, | |
| ) | |
| else: | |
| predicted_masks_upsampled = predicted_masks_filtered | |
| # BCE loss between predicted logits and DINOv3 pseudo-labels | |
| mask_bce_loss = F.binary_cross_entropy_with_logits( | |
| predicted_masks_upsampled, dinov3_masks_filtered | |
| ) | |
| # Total loss | |
| total_loss = loss_metrics_input.loss + self.weight_mask_distill * ( | |
| mask_bce_loss + input_mask_bce_loss | |
| ) | |
| # merge dict | |
| loss_metrics_all = edict( | |
| loss=total_loss, | |
| mask_bce_loss=mask_bce_loss, | |
| input_mask_bce_loss=input_mask_bce_loss, | |
| **{k: v for k, v in loss_metrics_input.items() if k != "loss"}, | |
| ) | |
| return loss_metrics_all | |