# 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 @staticmethod def _compute_error(real, fake): return torch.mean(torch.abs(real - fake)) @staticmethod 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