"""Visibility-aware radiomap decoder operating on gathered voxel context.""" from __future__ import annotations import torch import torch.nn as nn import torch.nn.functional as F def _resolve_group_count(channels: int, max_groups: int = 8) -> int: groups = min(max(int(max_groups), 1), max(int(channels), 1)) while groups > 1 and channels % groups != 0: groups -= 1 return max(groups, 1) class ResidualMLPBlock(nn.Module): """Residual MLP block used to deepen the decoder head stack.""" def __init__(self, dim: int, hidden_dim: int, dropout: float = 0.0) -> None: super().__init__() self.norm = nn.LayerNorm(dim) self.fc1 = nn.Linear(dim, hidden_dim) self.act = nn.GELU() self.drop1 = nn.Dropout(dropout) self.fc2 = nn.Linear(hidden_dim, dim) self.drop2 = nn.Dropout(dropout) def forward(self, x: torch.Tensor) -> torch.Tensor: residual = x x = self.norm(x) x = self.fc1(x) x = self.act(x) x = self.drop1(x) x = self.fc2(x) x = self.drop2(x) return residual + x class QueryHeadStem(nn.Module): """Shared token-space stem before scattering query features back to the RX plane.""" def __init__(self, input_dim: int, hidden_dim: int, depth: int, dropout: float = 0.0) -> None: super().__init__() self.input_proj = nn.Sequential( nn.LayerNorm(input_dim), nn.Linear(input_dim, hidden_dim), nn.GELU(), nn.Dropout(dropout), ) self.blocks = nn.ModuleList( [ResidualMLPBlock(hidden_dim, hidden_dim * 2, dropout=dropout) for _ in range(max(int(depth), 0))] ) def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.input_proj(x) for block in self.blocks: x = block(x) return x class DeepOutputHead(nn.Module): """Legacy deeper per-task MLP head kept for compatibility/debugging.""" def __init__(self, input_dim: int, hidden_dim: int, depth: int, out_dim: int = 1, dropout: float = 0.0) -> None: super().__init__() self.blocks = nn.ModuleList( [ResidualMLPBlock(input_dim, hidden_dim * 2, dropout=dropout) for _ in range(max(int(depth), 0))] ) self.out = nn.Sequential( nn.LayerNorm(input_dim), nn.Linear(input_dim, hidden_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_dim, out_dim), ) def forward(self, x: torch.Tensor) -> torch.Tensor: for block in self.blocks: x = block(x) return self.out(x) class DenseSelfAttentionBlock(nn.Module): """Pre-norm dense transformer block for query refinement.""" def __init__(self, dim: int, num_heads: int, mlp_ratio: float = 4.0, dropout: float = 0.0) -> None: super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True) self.norm2 = nn.LayerNorm(dim) self.mlp = nn.Sequential( nn.Linear(dim, int(dim * mlp_ratio)), nn.GELU(), nn.Linear(int(dim * mlp_ratio), dim), ) def forward(self, x: torch.Tensor, key_padding_mask: torch.Tensor | None = None) -> torch.Tensor: attn_out, _ = self.attn(self.norm1(x), self.norm1(x), self.norm1(x), key_padding_mask=key_padding_mask, need_weights=False) x = x + attn_out return x + self.mlp(self.norm2(x)) class DenseCrossAttentionBlock(nn.Module): """Pre-norm cross-attention block for query-to-memory fusion.""" def __init__(self, dim: int, context_dim: int, num_heads: int, mlp_ratio: float = 4.0, dropout: float = 0.0) -> None: super().__init__() self.norm_q = nn.LayerNorm(dim) self.norm_ctx = nn.LayerNorm(context_dim) self.attn = nn.MultiheadAttention( dim, num_heads, dropout=dropout, batch_first=True, kdim=context_dim, vdim=context_dim, ) self.norm2 = nn.LayerNorm(dim) self.mlp = nn.Sequential( nn.Linear(dim, int(dim * mlp_ratio)), nn.GELU(), nn.Linear(int(dim * mlp_ratio), dim), ) def forward(self, x: torch.Tensor, context: torch.Tensor, key_padding_mask: torch.Tensor | None = None) -> torch.Tensor: attn_out, _ = self.attn( self.norm_q(x), self.norm_ctx(context), self.norm_ctx(context), key_padding_mask=key_padding_mask, need_weights=False, ) x = x + attn_out return x + self.mlp(self.norm2(x)) class SpatialResidualBlock(nn.Module): """Conv residual block for spatial radiomap decoding.""" def __init__(self, channels: int, hidden_channels: int, dropout: float = 0.0) -> None: super().__init__() groups = _resolve_group_count(channels) hidden_groups = _resolve_group_count(hidden_channels) self.norm1 = nn.GroupNorm(groups, channels) self.conv1 = nn.Conv2d(channels, hidden_channels, kernel_size=3, padding=1) self.act = nn.GELU() self.drop1 = nn.Dropout2d(dropout) self.norm2 = nn.GroupNorm(hidden_groups, hidden_channels) self.conv2 = nn.Conv2d(hidden_channels, channels, kernel_size=3, padding=1) self.drop2 = nn.Dropout2d(dropout) def forward(self, x: torch.Tensor) -> torch.Tensor: residual = x x = self.norm1(x) x = self.conv1(x) x = self.act(x) x = self.drop1(x) x = self.norm2(x) x = self.conv2(x) x = self.drop2(x) return residual + x class SpatialHeadStem(nn.Module): """Shared spatial trunk inspired by the older conv-based radiomap head.""" def __init__(self, channels: int, depth: int, dropout: float = 0.0, use_coord_channels: bool = True) -> None: super().__init__() self.channels = int(channels) self.use_coord_channels = bool(use_coord_channels) extra_channels = 1 + (2 if self.use_coord_channels else 0) self.input_proj = nn.Sequential( nn.Conv2d(self.channels + extra_channels, self.channels, kernel_size=3, padding=1), nn.GroupNorm(_resolve_group_count(self.channels), self.channels), nn.GELU(), nn.Dropout2d(dropout), ) self.blocks = nn.ModuleList( [ SpatialResidualBlock( channels=self.channels, hidden_channels=max(self.channels * 2, self.channels + 32), dropout=dropout, ) for _ in range(max(int(depth), 1)) ] ) def forward(self, x: torch.Tensor, observed_mask: torch.Tensor, extent_mask: torch.Tensor) -> torch.Tensor: features = [x, observed_mask] if self.use_coord_channels: batch_size, _, height, width = x.shape yy = torch.linspace(-1.0, 1.0, steps=height, device=x.device, dtype=x.dtype) xx = torch.linspace(-1.0, 1.0, steps=width, device=x.device, dtype=x.dtype) grid_y, grid_x = torch.meshgrid(yy, xx, indexing="ij") coord = torch.stack([grid_x, grid_y], dim=0).unsqueeze(0).expand(batch_size, -1, -1, -1) features.append(coord) x = self.input_proj(torch.cat(features, dim=1)) x = x * extent_mask for block in self.blocks: x = block(x) * extent_mask return x class SpatialOutputHead(nn.Module): """Small U-Net-like conv head that predicts dense plane outputs.""" def __init__(self, channels: int, hidden_channels: int, depth: int, out_channels: int = 1, dropout: float = 0.0) -> None: super().__init__() self.pre_blocks = nn.ModuleList( [ SpatialResidualBlock( channels=channels, hidden_channels=max(channels * 2, channels + 32), dropout=dropout, ) for _ in range(max(int(depth), 1)) ] ) self.down = nn.Sequential( nn.GroupNorm(_resolve_group_count(channels), channels), nn.GELU(), nn.Conv2d(channels, hidden_channels, kernel_size=3, stride=2, padding=1), ) self.bottleneck_blocks = nn.ModuleList( [ SpatialResidualBlock( channels=hidden_channels, hidden_channels=max(hidden_channels * 2, hidden_channels + 32), dropout=dropout, ) for _ in range(max(int(depth), 1)) ] ) self.up_proj = nn.Sequential( nn.GroupNorm(_resolve_group_count(hidden_channels), hidden_channels), nn.GELU(), nn.Conv2d(hidden_channels, channels, kernel_size=3, padding=1), ) self.fuse = nn.Sequential( nn.Conv2d(channels * 2, channels, kernel_size=3, padding=1), nn.GroupNorm(_resolve_group_count(channels), channels), nn.GELU(), ) self.refine_blocks = nn.ModuleList( [ SpatialResidualBlock( channels=channels, hidden_channels=max(channels * 2, channels + 32), dropout=dropout, ) for _ in range(max(int(depth) - 1, 0)) ] ) self.out = nn.Conv2d(channels, out_channels, kernel_size=1) def forward(self, x: torch.Tensor, extent_mask: torch.Tensor) -> torch.Tensor: skip = x * extent_mask for block in self.pre_blocks: skip = block(skip) * extent_mask down = self.down(skip) down_mask = F.interpolate(extent_mask, size=down.shape[-2:], mode="nearest") down = down * down_mask for block in self.bottleneck_blocks: down = block(down) * down_mask up = self.up_proj(down) up = F.interpolate(up, size=skip.shape[-2:], mode="bilinear", align_corners=False) fused = self.fuse(torch.cat([skip, up], dim=1)) * extent_mask for block in self.refine_blocks: fused = block(fused) * extent_mask return self.out(fused) * extent_mask class VisibilityAwareQueryDecoder(nn.Module): """Fuse per-query voxel memory and predict radiomap outputs.""" def __init__( self, *, query_dim: int, memory_dim: int, decoder_dim: int, num_heads: int, cross_depth: int, self_depth: int, output_head_hidden_dim: int = 0, output_head_shared_depth: int = 2, output_head_branch_depth: int = 2, output_head_dropout: float = 0.0, mlp_ratio: float = 4.0, dropout: float = 0.0, predict_valid: bool = False, predict_visibility: bool = False, predict_boundary: bool = False, predict_uncertainty: bool = False, use_dual_gain_heads: bool = False, use_los_residual_heads: bool = False, ) -> None: super().__init__() self.predict_valid = bool(predict_valid) self.predict_visibility = bool(predict_visibility) self.predict_boundary = bool(predict_boundary) self.predict_uncertainty = bool(predict_uncertainty) self.use_los_residual_heads = bool(use_los_residual_heads) requested_dual_gain = bool(use_dual_gain_heads) if self.use_los_residual_heads and requested_dual_gain: raise ValueError("use_los_residual_heads and use_dual_gain_heads are mutually exclusive") if self.use_los_residual_heads and not self.predict_visibility: self.predict_visibility = True self.use_dual_gain_heads = bool(requested_dual_gain and self.predict_visibility) head_hidden_dim = int(output_head_hidden_dim) if int(output_head_hidden_dim) > 0 else int(decoder_dim * 2) spatial_head_dim = min(max(head_hidden_dim // 2, 96), 256) aux_hidden_dim = min(max(spatial_head_dim, 96), 256) self.query_proj = nn.Sequential(nn.Linear(query_dim, decoder_dim), nn.LayerNorm(decoder_dim)) self.memory_proj = nn.Sequential(nn.Linear(memory_dim, decoder_dim), nn.LayerNorm(decoder_dim)) self.cross_blocks = nn.ModuleList( [ DenseCrossAttentionBlock( dim=decoder_dim, context_dim=decoder_dim, num_heads=num_heads, mlp_ratio=mlp_ratio, dropout=dropout, ) for _ in range(max(int(cross_depth), 1)) ] ) self.self_blocks = nn.ModuleList( [ DenseSelfAttentionBlock( dim=decoder_dim, num_heads=num_heads, mlp_ratio=mlp_ratio, dropout=dropout, ) for _ in range(max(int(self_depth), 0)) ] ) self.head_stem = QueryHeadStem( input_dim=int(decoder_dim), hidden_dim=head_hidden_dim, depth=int(output_head_shared_depth), dropout=float(output_head_dropout), ) self.spatial_token_proj = nn.Sequential( nn.LayerNorm(head_hidden_dim), nn.Linear(head_hidden_dim, spatial_head_dim), nn.GELU(), nn.Dropout(output_head_dropout), ) self.spatial_stem = SpatialHeadStem( channels=spatial_head_dim, depth=int(output_head_shared_depth), dropout=float(output_head_dropout), use_coord_channels=True, ) self.path_gain_head = ( None if (self.use_dual_gain_heads or self.use_los_residual_heads) else SpatialOutputHead( channels=spatial_head_dim, hidden_channels=aux_hidden_dim, depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) ) self.valid_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=max(aux_hidden_dim // 2, 64), depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.predict_valid else None ) self.visibility_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=max(aux_hidden_dim // 2, 64), depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.predict_visibility else None ) self.boundary_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=max(aux_hidden_dim // 2, 64), depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.predict_boundary else None ) self.clear_gain_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=aux_hidden_dim, depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.use_dual_gain_heads else None ) self.blocked_gain_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=aux_hidden_dim, depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.use_dual_gain_heads else None ) self.los_gain_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=aux_hidden_dim, depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.use_los_residual_heads else None ) self.residual_gain_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=aux_hidden_dim, depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.use_los_residual_heads else None ) self.uncertainty_head = ( SpatialOutputHead( channels=spatial_head_dim, hidden_channels=max(aux_hidden_dim // 2, 64), depth=int(output_head_branch_depth), out_channels=1, dropout=float(output_head_dropout), ) if self.predict_uncertainty else None ) @staticmethod def _db_to_power(db: torch.Tensor) -> torch.Tensor: return torch.pow(10.0, db.clamp(min=-200.0, max=80.0) / 10.0) @staticmethod def _power_to_db(power: torch.Tensor) -> torch.Tensor: return 10.0 * torch.log10(power.clamp_min(1e-20)) @staticmethod def _build_extent_mask(grid_shape: torch.Tensor, max_h: int, max_w: int, dtype: torch.dtype) -> torch.Tensor: batch_size = int(grid_shape.shape[0]) mask = torch.zeros((batch_size, 1, max_h, max_w), dtype=dtype, device=grid_shape.device) for batch_idx in range(batch_size): grid_h = int(grid_shape[batch_idx, 0].item()) grid_w = int(grid_shape[batch_idx, 1].item()) mask[batch_idx, :, :grid_h, :grid_w] = 1.0 return mask @staticmethod def _scatter_query_tokens_to_dense( token_features: torch.Tensor, grid_shape: torch.Tensor, rx_flat_index: torch.Tensor, query_padding_mask: torch.Tensor | None, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: batch_size, num_queries, channels = token_features.shape max_h = int(grid_shape[:, 0].max().item()) max_w = int(grid_shape[:, 1].max().item()) dense_flat = token_features.new_zeros((batch_size, channels, max_h * max_w)) count_flat = token_features.new_zeros((batch_size, 1, max_h * max_w)) if query_padding_mask is None: valid_queries = torch.ones((batch_size, num_queries), dtype=torch.bool, device=token_features.device) else: valid_queries = query_padding_mask > 0.5 valid_queries = valid_queries & (rx_flat_index >= 0) for batch_idx in range(batch_size): sample_valid = valid_queries[batch_idx] if not bool(sample_valid.any()): continue sample_index = rx_flat_index[batch_idx, sample_valid].long() sample_tokens = token_features[batch_idx, sample_valid] grid_w = int(grid_shape[batch_idx, 1].item()) rows = torch.div(sample_index, grid_w, rounding_mode="floor") cols = sample_index.remainder(grid_w) dense_index = rows * max_w + cols dense_flat[batch_idx].scatter_add_( 1, dense_index.unsqueeze(0).expand(channels, -1), sample_tokens.transpose(0, 1), ) count_flat[batch_idx, 0].scatter_add_( 0, dense_index, torch.ones_like(dense_index, dtype=token_features.dtype), ) observed_mask = (count_flat > 0).view(batch_size, 1, max_h, max_w).to(dtype=token_features.dtype) dense = (dense_flat / count_flat.clamp_min(1.0)).view(batch_size, channels, max_h, max_w) extent_mask = VisibilityAwareQueryDecoder._build_extent_mask(grid_shape, max_h, max_w, token_features.dtype) return dense * extent_mask, observed_mask, extent_mask @staticmethod def _gather_dense_predictions( dense_map: torch.Tensor, grid_shape: torch.Tensor, rx_flat_index: torch.Tensor, ) -> torch.Tensor: batch_size, channels, _, max_w = dense_map.shape flat_map = dense_map.flatten(start_dim=2) outputs = [] for batch_idx in range(batch_size): sample_index = rx_flat_index[batch_idx].long() sample_out = dense_map.new_zeros((channels, sample_index.numel())) valid = sample_index >= 0 if bool(valid.any()): grid_w = int(grid_shape[batch_idx, 1].item()) rows = torch.div(sample_index[valid], grid_w, rounding_mode="floor") cols = sample_index[valid].remainder(grid_w) dense_index = rows * max_w + cols sample_out[:, valid] = flat_map[batch_idx].index_select(1, dense_index) outputs.append(sample_out.transpose(0, 1)) return torch.stack(outputs, dim=0) def _apply_spatial_head( self, head: SpatialOutputHead | None, dense_hidden: torch.Tensor, extent_mask: torch.Tensor, grid_shape: torch.Tensor, rx_flat_index: torch.Tensor, ) -> torch.Tensor | None: if head is None: return None dense_map = head(dense_hidden, extent_mask) gathered = self._gather_dense_predictions(dense_map, grid_shape=grid_shape, rx_flat_index=rx_flat_index) return gathered.squeeze(-1) def forward( self, *, query_tokens: torch.Tensor, memory_tokens: torch.Tensor, memory_valid_mask: torch.Tensor, grid_shape: torch.Tensor, rx_flat_index: torch.Tensor, query_padding_mask: torch.Tensor | None = None, ) -> dict[str, torch.Tensor]: x = self.query_proj(query_tokens) memory = self.memory_proj(memory_tokens) if memory.dim() == 4: batch_size, num_queries, memory_len, dim = memory.shape flat_query = x.view(batch_size * num_queries, 1, dim) flat_memory = memory.view(batch_size * num_queries, memory_len, dim) flat_memory_invalid = (~memory_valid_mask.bool()).view(batch_size * num_queries, memory_len) for block in self.cross_blocks: flat_query = block(flat_query, flat_memory, key_padding_mask=flat_memory_invalid) x = flat_query.view(batch_size, num_queries, dim) elif memory.dim() == 3: memory_invalid = ~memory_valid_mask.bool() for block in self.cross_blocks: x = block(x, memory, key_padding_mask=memory_invalid) else: raise ValueError(f"Unsupported memory token rank: {memory.dim()}") query_invalid = None if query_padding_mask is not None: query_invalid = ~(query_padding_mask > 0.5) for block in self.self_blocks: x = block(x, key_padding_mask=query_invalid) head_hidden = self.head_stem(x) spatial_tokens = self.spatial_token_proj(head_hidden) dense_hidden, observed_mask, extent_mask = self._scatter_query_tokens_to_dense( spatial_tokens, grid_shape=grid_shape, rx_flat_index=rx_flat_index, query_padding_mask=query_padding_mask, ) dense_hidden = self.spatial_stem(dense_hidden, observed_mask=observed_mask, extent_mask=extent_mask) if self.use_los_residual_heads: los_gain_db = self._apply_spatial_head(self.los_gain_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) residual_gain_db = self._apply_spatial_head(self.residual_gain_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) los_logits = self._apply_spatial_head(self.visibility_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) assert los_gain_db is not None and residual_gain_db is not None and los_logits is not None los_prob = torch.sigmoid(los_logits) total_power = los_prob * self._db_to_power(los_gain_db) + self._db_to_power(residual_gain_db) path_gain_db = self._power_to_db(total_power) clear_path_gain_db = los_gain_db blocked_path_gain_db = residual_gain_db visibility_logits = los_logits elif self.use_dual_gain_heads: clear_path_gain_db = self._apply_spatial_head(self.clear_gain_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) blocked_path_gain_db = self._apply_spatial_head(self.blocked_gain_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) visibility_logits = self._apply_spatial_head(self.visibility_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) assert clear_path_gain_db is not None and blocked_path_gain_db is not None and visibility_logits is not None visibility_prob = torch.sigmoid(visibility_logits) path_gain_db = visibility_prob * clear_path_gain_db + (1.0 - visibility_prob) * blocked_path_gain_db else: path_gain_db = self._apply_spatial_head(self.path_gain_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) assert path_gain_db is not None clear_path_gain_db = path_gain_db blocked_path_gain_db = path_gain_db visibility_logits = self._apply_spatial_head(self.visibility_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) outputs = { "query_tokens": x, "path_gain_db": path_gain_db, "base_path_gain_db": clear_path_gain_db, "coarse_path_gain_db": clear_path_gain_db, } if self.predict_valid and self.valid_head is not None: valid_logits = self._apply_spatial_head(self.valid_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) if valid_logits is not None: outputs["valid_logits"] = valid_logits if self.use_dual_gain_heads or self.use_los_residual_heads: outputs["clear_path_gain_db"] = clear_path_gain_db outputs["blocked_path_gain_db"] = blocked_path_gain_db if self.predict_visibility and visibility_logits is not None: outputs["visibility_logits"] = visibility_logits outputs["occlusion_logits"] = -visibility_logits if self.use_dual_gain_heads or self.use_los_residual_heads: outputs["residual_path_gain_db"] = ( blocked_path_gain_db if self.use_los_residual_heads else blocked_path_gain_db - clear_path_gain_db ) if self.use_los_residual_heads and visibility_logits is not None: outputs["los_logits"] = visibility_logits outputs["los_gain_db"] = clear_path_gain_db outputs["residual_multipath_gain_db"] = blocked_path_gain_db outputs["los_prob"] = torch.sigmoid(visibility_logits) if self.predict_boundary and self.boundary_head is not None: boundary_logits = self._apply_spatial_head(self.boundary_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) if boundary_logits is not None: outputs["boundary_logits"] = boundary_logits outputs["boundary_prob"] = torch.sigmoid(boundary_logits) if self.predict_uncertainty and self.uncertainty_head is not None: uncertainty_logits = self._apply_spatial_head(self.uncertainty_head, dense_hidden, extent_mask, grid_shape, rx_flat_index) if uncertainty_logits is not None: outputs["uncertainty_logits"] = uncertainty_logits return outputs __all__ = [ "DeepOutputHead", "DenseCrossAttentionBlock", "DenseSelfAttentionBlock", "QueryHeadStem", "ResidualMLPBlock", "SpatialHeadStem", "SpatialOutputHead", "SpatialResidualBlock", "VisibilityAwareQueryDecoder", ]