"""Dense localization decoder for A-EYE PatchGuard. This model keeps the image-level body and the coarse PatchGuard head compatible with models 54-61, then adds a small convolutional decoder that refines the 16x16 patch logits into a denser mask (default 32x32). The dense head is trained directly against mask-derived targets, so localization can improve without throwing away the stable image decision learned by model 49/60/61. """ from __future__ import annotations import math import torch import torch.nn as nn import torch.nn.functional as F from aeye_next.models.patchguard import PatchGuardDetector class _ConvBlock(nn.Module): def __init__(self, channels: int, dropout: float = 0.0) -> None: super().__init__() groups = max(1, min(8, channels // 16)) self.net = nn.Sequential( nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False), nn.GroupNorm(groups, channels), nn.GELU(), nn.Dropout2d(dropout), nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False), nn.GroupNorm(groups, channels), ) self.act = nn.GELU() def forward(self, x: torch.Tensor) -> torch.Tensor: return self.act(x + self.net(x)) class DensePatchGuardDetector(PatchGuardDetector): def __init__( self, *args, decoder_grid: int = 32, decoder_channels: int = 192, decoder_blocks: int = 3, decoder_dropout: float = 0.04, **kwargs, ) -> None: super().__init__(*args, **kwargs) if decoder_grid < 16 or decoder_grid % 16 != 0: raise ValueError("decoder_grid must be a multiple of 16 and >= 16") self.decoder_grid = int(decoder_grid) self.decoder_channels = int(decoder_channels) clip_hidden = int(self.clip.config.hidden_size) forensic_map_dim = 192 self.clip_map_proj = nn.Sequential( nn.LayerNorm(clip_hidden), nn.Linear(clip_hidden, decoder_channels), ) self.forensic_map_proj = nn.Conv2d(forensic_map_dim, decoder_channels, kernel_size=1) self.dense_decoder = nn.Sequential( *[_ConvBlock(decoder_channels, dropout=decoder_dropout) for _ in range(int(decoder_blocks))] ) self.mask_delta_head = nn.Sequential( nn.Conv2d(decoder_channels, decoder_channels // 2, kernel_size=3, padding=1), nn.GELU(), nn.Conv2d(decoder_channels // 2, 1, kernel_size=1), ) # Start as "model 61 upsampled": the new decoder initially contributes # almost nothing, then learns boundary/detail corrections. nn.init.zeros_(self.mask_delta_head[-1].weight) nn.init.zeros_(self.mask_delta_head[-1].bias) def _dense_logits( self, tokens: torch.Tensor, forensic_map: torch.Tensor, coarse_logits: torch.Tensor, grid: int, ) -> torch.Tensor: bsz = tokens.shape[0] clip_map = self.clip_map_proj(tokens).transpose(1, 2).reshape( bsz, self.decoder_channels, grid, grid ) fmap = F.interpolate( forensic_map.float(), size=(grid, grid), mode="bilinear", align_corners=False ) fused = clip_map + self.forensic_map_proj(fmap) if self.decoder_grid != grid: fused = F.interpolate( fused, size=(self.decoder_grid, self.decoder_grid), mode="bilinear", align_corners=False, ) decoded = self.dense_decoder(fused) delta = self.mask_delta_head(decoded).squeeze(1) coarse = F.interpolate( coarse_logits.unsqueeze(1), size=(self.decoder_grid, self.decoder_grid), mode="bilinear", align_corners=False, ).squeeze(1) return coarse + delta def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: raw = self._to_raw_rgb(x) tokens = self._clip_tokens(x).float() pooled = self.semantic_pool(tokens) semantic = self.semantic_proj(pooled) forensic_map = self._forensic_spatial(raw) forensic_vec = self.forensic_branch.proj( self.forensic_branch.pool(forensic_map).flatten(1) ) frequency = self.frequency_branch(raw) features = torch.cat([semantic, forensic_vec, frequency], dim=1) logits = self.head(features) grid = int(math.sqrt(tokens.shape[1])) fmap = F.interpolate( forensic_map.float(), size=(grid, grid), mode="bilinear", align_corners=False ) fmap_tokens = fmap.flatten(2).transpose(1, 2) coarse_logits = self.patch_head(torch.cat([tokens, fmap_tokens], dim=-1)).squeeze(-1) coarse_logits = coarse_logits.view(-1, grid, grid) patch_logits = self._dense_logits(tokens, forensic_map, coarse_logits, grid) flat = patch_logits.flatten(1) k = max(1, int(round(flat.shape[1] * self.patch_topk_frac))) patch_summary = flat.topk(k, dim=1).values.mean(dim=1) patch_peak = flat.max(dim=1).values z_img = ( (logits[:, 1] - logits[:, 0]) + self.gamma.squeeze() * patch_summary + self.gamma_max.squeeze() * patch_peak ) return { "logits": logits, "z_img": z_img, "patch_logits": patch_logits, "coarse_patch_logits": coarse_logits, "patch_summary": patch_summary, "uncertainty_logit": self.uncertainty_head(features).squeeze(1), "features": features, } def param_summary(self) -> str: base = super().param_summary() dense = sum( p.numel() for name, p in self.named_parameters() if name.startswith(("clip_map_proj.", "forensic_map_proj.", "dense_decoder.", "mask_delta_head.")) ) return f"{base} dense_grid={self.decoder_grid} dense_params={dense:,}" def build_dense_patchguard(cfg: dict) -> DensePatchGuardDetector: mcfg = dict(cfg.get("model", {})) mcfg.pop("type", None) return DensePatchGuardDetector(**mcfg)