MohsinElis commited on
Commit
3862e2a
·
verified ·
1 Parent(s): e83ff6c

Deploy production Patch-MFFT Tampering Forensics API

Browse files
Files changed (2) hide show
  1. api/model_server.py +18 -13
  2. model/src/model.py +256 -282
api/model_server.py CHANGED
@@ -51,9 +51,9 @@ class TamperingModelServer:
51
  state = torch.load(path, map_location=self.device, weights_only=False)
52
 
53
  raw_state = state["model_state_dict"] if isinstance(state, dict) and "model_state_dict" in state else state
54
- self.model.load_state_dict(raw_state, strict=False)
55
  self.is_loaded = True
56
- print(f"[TamperingModelServer] Successfully loaded weights from {path}")
57
 
58
  def warmup(self, iters: int = 2):
59
  dummy = torch.randn(1, 3, self.img_size, self.img_size).to(self.device)
@@ -63,18 +63,11 @@ class TamperingModelServer:
63
  print(f"[TamperingModelServer] Warmup completed ({iters} iterations).")
64
 
65
  def _preprocess(self, image: Image.Image) -> torch.Tensor:
66
- w, h = image.size
67
- target = self.img_size + 16
68
- if w < h:
69
- new_w, new_h = target, max(target, int(h * target / w))
70
- else:
71
- new_h, new_w = target, max(target, int(w * target / h))
72
- image = image.convert("RGB").resize((new_w, new_h), Image.Resampling.BILINEAR)
73
- left = max(0, (new_w - self.img_size) // 2)
74
- top = max(0, (new_h - self.img_size) // 2)
75
- image = image.crop((left, top, left + self.img_size, top + self.img_size))
76
 
77
- img_array = np.array(image, dtype=np.float32) / 255.0
78
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
79
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
80
  img_array = ((img_array - mean) / std).astype(np.float32)
@@ -102,6 +95,18 @@ class TamperingModelServer:
102
 
103
  # Mask to Base64 PNG (apply sigmoid to convert raw logits to probabilities)
104
  mask_np = torch.sigmoid(mask)[0, 0].cpu().numpy()
 
 
 
 
 
 
 
 
 
 
 
 
105
  mask_uint8 = np.clip(mask_np * 255.0, 0, 255).astype(np.uint8)
106
  mask_pil = Image.fromarray(mask_uint8, mode="L")
107
  buf = io.BytesIO()
 
51
  state = torch.load(path, map_location=self.device, weights_only=False)
52
 
53
  raw_state = state["model_state_dict"] if isinstance(state, dict) and "model_state_dict" in state else state
54
+ self.model.load_state_dict(raw_state, strict=True)
55
  self.is_loaded = True
56
+ print(f"[TamperingModelServer] Successfully loaded weights with strict=True from {path}")
57
 
58
  def warmup(self, iters: int = 2):
59
  dummy = torch.randn(1, 3, self.img_size, self.img_size).to(self.device)
 
63
  print(f"[TamperingModelServer] Warmup completed ({iters} iterations).")
64
 
65
  def _preprocess(self, image: Image.Image) -> torch.Tensor:
66
+ # Standardize directly to model input resolution via bilinear resampling,
67
+ # perfectly matching the training dataset pipeline (Cell 14 of kaggle_patch_mfft_pipeline)
68
+ img_res = image.convert("RGB").resize((self.img_size, self.img_size), Image.Resampling.BILINEAR)
 
 
 
 
 
 
 
69
 
70
+ img_array = np.array(img_res, dtype=np.float32) / 255.0
71
  mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
72
  std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
73
  img_array = ((img_array - mean) / std).astype(np.float32)
 
95
 
96
  # Mask to Base64 PNG (apply sigmoid to convert raw logits to probabilities)
97
  mask_np = torch.sigmoid(mask)[0, 0].cpu().numpy()
98
+
99
+ # Forensic mask gating:
100
+ # 1. Authentic Camera: Zero out tampering mask (untouched camera capture)
101
+ # 2. Full AI: Retain full frame generative anomaly heatmap
102
+ # 3. Spliced/Tampered: Suppress background noise (< 0.20) to sharply isolate forged regions
103
+ if pred_idx == 0:
104
+ mask_np = np.zeros_like(mask_np)
105
+ elif pred_idx == 1:
106
+ mask_np = np.clip(mask_np, 0.0, 1.0)
107
+ else:
108
+ mask_np = np.where(mask_np > 0.20, mask_np, 0.0)
109
+
110
  mask_uint8 = np.clip(mask_np * 255.0, 0, 255).astype(np.uint8)
111
  mask_pil = Image.fromarray(mask_uint8, mode="L")
112
  buf = io.BytesIO()
model/src/model.py CHANGED
@@ -1,27 +1,24 @@
1
  """
2
- Patch-MFFT: Dual-Branch Frequency Attention Transformer for
3
  Localized Generative Inpainting, Splicing Extortion, and Style Tampering Forensics.
4
 
5
- Core Innovations:
6
- 1. Radial 2D-FFT Annular Sub-band Decomposition with exact reconstruction.
7
- 2. Dual-Branch Spatial-Max Gating (SMG):
8
- Combines Global Average Pooling (macro scene context) with Spatial Max-Pooling
9
- (localized tampering activation) so small spliced patches (~5-15% of frame)
10
- are never washed out by the authentic camera background.
11
- 3. Boundary Frequency-Discontinuity Attention (BFDA):
12
- Isolates high-frequency phase and energy mismatch seams along splicing perimeters.
13
- 4. Dual Task Output:
14
- - 3-Class Categorical Head:
15
- * 0: Authentic Photography (Untouched camera capture)
16
- * 1: Fully AI-Generated (Synthesized end-to-end from random noise)
17
- * 2: Locally Spliced / Inpainted / Tampered (Multi-person composites, generative fill)
18
- - Pixel-Level Tampering Localization Mask Head (1 x H x W probability map).
19
  """
20
 
21
  import math
22
  from typing import Dict, List, Optional, Tuple, Union
23
 
24
- import numpy as np
25
  import torch
26
  import torch.nn as nn
27
  import torch.nn.functional as F
@@ -29,202 +26,211 @@ import torch.nn.functional as F
29
 
30
  class RadialFourierDecomposition(nn.Module):
31
  """
32
- Decomposes an input image into K non-overlapping radial annular frequency bands
33
  in the 2D Fast Fourier Transform (FFT) domain.
34
- Guarantees the exact reconstruction property: sum_{k=1}^K B_k = X.
35
  """
36
 
37
- def __init__(self, img_size: int = 384, num_bands: int = 3, band_configs: Optional[List[Tuple[float, float]]] = None):
38
  super().__init__()
39
  self.img_size = img_size
40
  self.num_bands = num_bands
41
-
42
- if band_configs is not None:
43
- self.configs = band_configs
44
- self.num_bands = len(band_configs)
45
- elif num_bands == 2:
46
- self.configs = [(0.0, 0.25), (0.25, 1.0)]
47
- elif num_bands == 4:
48
- self.configs = [(0.0, 0.125), (0.125, 0.25), (0.25, 0.5), (0.5, 1.0)]
49
- else:
50
- # Default 3 bands: Low, Mid, High
51
- self.configs = [(0.0, 0.15), (0.15, 0.5), (0.5, 1.0)]
52
-
53
- # Precompute radial coordinate grid
54
  y = torch.linspace(-1, 1, img_size)
55
  x = torch.linspace(-1, 1, img_size)
56
  gy, gx = torch.meshgrid(y, x, indexing="ij")
57
- r = torch.sqrt(gx**2 + gy**2)
58
- r = r / r.max() # normalize [0, 1]
59
-
60
  masks = []
61
- for low_r, high_r in self.configs:
62
- if high_r >= 1.0:
63
- mask = (r >= low_r) & (r <= high_r)
64
- else:
65
- mask = (r >= low_r) & (r < high_r)
66
- masks.append(mask.float())
67
-
68
- # Ensure exact reconstruction without energy leakage
69
- stacked = torch.stack(masks, dim=0) # (K, H, W)
70
- norm_factor = stacked.sum(dim=0, keepdim=True).clamp(min=1e-8)
71
- stacked = stacked / norm_factor
72
- self.register_buffer("masks", stacked)
73
 
74
  def forward(self, x: torch.Tensor) -> List[torch.Tensor]:
75
- """
76
- Args:
77
- x: Input image tensor (B, C, H, W)
78
- Returns:
79
- List of K decomposed spatial-frequency bands [B_1, B_2, ..., B_K]
80
- """
81
- B, C, H, W = x.shape
82
- # Real-to-complex 2D FFT
83
  X_fft = torch.fft.fftshift(torch.fft.fft2(x, norm="ortho"), dim=(-2, -1))
84
-
85
  bands = []
86
  for k in range(self.num_bands):
87
- mask = self.masks[k].unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
88
- filtered_fft = X_fft * mask
89
- unshifted = torch.fft.ifftshift(filtered_fft, dim=(-2, -1))
90
- spatial_band = torch.fft.ifft2(unshifted, norm="ortho").real
91
- bands.append(spatial_band)
92
-
93
  return bands
94
 
95
 
96
- class DepthwiseSeparableConvBlock(nn.Module):
97
- """Parameter-efficient depthwise separable convolutional residual block."""
98
-
99
- def __init__(self, in_channels: int, out_channels: int, stride: int = 1):
100
  super().__init__()
101
- self.depthwise = nn.Conv2d(
102
- in_channels, in_channels, kernel_size=3, stride=stride, padding=1,
103
- groups=in_channels, bias=False
104
- )
105
- self.pointwise = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
106
- self.bn1 = nn.BatchNorm2d(out_channels)
107
- self.act1 = nn.GELU()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- self.depthwise2 = nn.Conv2d(
110
- out_channels, out_channels, kernel_size=3, stride=1, padding=1,
111
- groups=out_channels, bias=False
112
- )
113
- self.pointwise2 = nn.Conv2d(out_channels, out_channels, kernel_size=1, bias=False)
114
- self.bn2 = nn.BatchNorm2d(out_channels)
 
 
 
 
 
115
 
116
- self.residual = (
117
- nn.Sequential(
118
- nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
119
- nn.BatchNorm2d(out_channels),
120
- )
121
- if (stride != 1 or in_channels != out_channels)
122
- else nn.Identity()
123
- )
124
- self.act2 = nn.GELU()
125
 
126
- def forward(self, x: torch.Tensor) -> torch.Tensor:
127
- res = self.residual(x)
128
- out = self.act1(self.bn1(self.pointwise(self.depthwise(x))))
129
- out = self.bn2(self.pointwise2(self.depthwise2(out)))
130
- return self.act2(out + res)
 
 
 
 
 
131
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- class FrequencyGuidedAttention(nn.Module):
134
- """
135
- Physical Fourier Energy Prior Gating.
136
- Dynamically modulates cross-attention weights based on logarithmic spectral energy.
137
- """
138
 
139
- def __init__(self, embed_dim: int, num_heads: int = 4):
 
140
  super().__init__()
141
- self.embed_dim = embed_dim
142
- self.num_heads = num_heads
143
- self.mha = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)
144
- self.norm = nn.LayerNorm(embed_dim)
145
- self.freq_gate = nn.Sequential(
146
- nn.Linear(1, embed_dim),
147
- nn.Sigmoid(),
148
- )
149
 
150
- def forward(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor,
151
- energy_prior: Optional[torch.Tensor] = None) -> torch.Tensor:
152
- # query, key, value: (B, N, D)
153
- attn_out, _ = self.mha(query, key, value)
154
- out = self.norm(query + attn_out)
155
 
156
- if energy_prior is not None:
157
- # energy_prior: (B, 1)
158
- gate = self.freq_gate(energy_prior.unsqueeze(-1)) # (B, 1, D)
159
- out = out * (1.0 + gate)
160
 
161
- return out
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
- class DualBranchSpatialMaxGating(nn.Module):
 
 
 
 
 
 
165
  """
166
- Dual-Branch Spatial-Max Gating (SMG).
167
- Crucial for localized tampering detection (splicing & inpainting).
168
- Branch A: Global Average Pool -> captures holistic scene illumination & context.
169
- Branch B: Patch Spatial Max Pool -> isolates localized high-frequency tampering spikes.
170
  """
171
 
172
- def __init__(self, in_features: int, out_features: int):
173
  super().__init__()
174
- # Concatenates average and max features (2 * in_features -> out_features)
175
- self.proj = nn.Sequential(
176
- nn.Linear(in_features * 2, out_features),
177
- nn.BatchNorm1d(out_features),
178
- nn.GELU(),
179
- nn.Dropout(0.1),
180
- )
 
 
 
 
 
 
 
 
181
 
182
- def forward(self, x: torch.Tensor) -> torch.Tensor:
183
- """
184
- Args:
185
- x: Feature map (B, C, H, W)
186
- Returns:
187
- Aggregated feature vector (B, out_features)
188
- """
189
- feat_avg = x.mean(dim=[-2, -1]) # (B, C) - Macro scene distribution
190
- feat_max = x.amax(dim=[-2, -1]) # (B, C) - Local tampering peak activation
191
- combined = torch.cat([feat_avg, feat_max], dim=-1) # (B, 2*C)
192
- return self.proj(combined)
 
 
 
 
 
 
193
 
194
 
195
  class TamperingLocalizationDecoder(nn.Module):
196
  """
197
- Pixel-level segmentation mask decoder.
198
- Takes fused multi-scale frequency features and predicts a 1-channel probability heatmap
199
- localizing the exact spliced/inpainted boundary and forged region.
200
  """
201
 
202
- def __init__(self, in_channels: int, img_size: int = 384):
203
  super().__init__()
204
- self.img_size = img_size
205
- self.decoder = nn.Sequential(
206
- nn.Conv2d(in_channels, 64, kernel_size=3, padding=1),
207
  nn.BatchNorm2d(64),
208
  nn.GELU(),
209
  nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
210
- nn.Conv2d(64, 32, kernel_size=3, padding=1),
211
  nn.BatchNorm2d(32),
212
  nn.GELU(),
213
  nn.Upsample(size=(img_size, img_size), mode="bilinear", align_corners=False),
214
- nn.Conv2d(32, 1, kernel_size=1),
215
  )
216
 
217
- def forward(self, feature_map: torch.Tensor) -> torch.Tensor:
218
- # feature_map: (B, C, H', W')
219
- return self.decoder(feature_map)
220
 
221
 
222
  class PatchMFFT(nn.Module):
223
  """
224
- Patch-MFFT Neural Architecture.
225
- Complete multi-frequency vision transformer for holistic synthesis detection,
226
- localized inpainting detection, two-person composite splicing detection,
227
- and pixel-wise tampering localization.
228
  """
229
 
230
  def __init__(
@@ -234,47 +240,58 @@ class PatchMFFT(nn.Module):
234
  depth: int = 3,
235
  num_bands: int = 3,
236
  img_size: int = 384,
237
- num_classes: int = 3, # 0: Authentic, 1: Fully AI-Generated, 2: Locally Spliced/Inpainted
238
- use_fga: bool = True,
239
- enable_localization_head: bool = True,
240
  ):
241
  super().__init__()
242
  self.embed_dim = embed_dim
243
  self.num_bands = num_bands
244
  self.img_size = img_size
245
- self.num_classes = num_classes
246
- self.use_fga = use_fga
247
- self.enable_localization_head = enable_localization_head
248
-
249
- # 1. 2D-FFT Radial Annular Spectral Decomposer
250
- self.decomposer = RadialFourierDecomposition(img_size=img_size, num_bands=num_bands)
251
-
252
- # 2. Multi-band convolutional feature extractors
253
- channels_prog = [32, 64, 128, embed_dim]
254
- self.band_encoders = nn.ModuleList()
255
- for _ in range(num_bands):
256
- blocks = []
257
- curr_c = 3
258
- for next_c in channels_prog:
259
- blocks.append(DepthwiseSeparableConvBlock(curr_c, next_c, stride=2))
260
- curr_c = next_c
261
- self.band_encoders.append(nn.Sequential(*blocks))
262
-
263
- # Spatial resolution after 4 stride-2 stages: 384 / 16 = 24x24
264
- self.spatial_h = img_size // 16
265
- self.spatial_w = img_size // 16
266
-
267
- # 3. Cross-Attention Band Fusion (CAF) with FGA
268
- self.fusion_blocks = nn.ModuleList([
269
- FrequencyGuidedAttention(embed_dim, num_heads=num_heads)
270
- for _ in range(depth)
271
  ])
272
 
273
- # 4. Dual-Branch Spatial-Max Gating (SMG)
274
- self.smg_aggregator = DualBranchSpatialMaxGating(embed_dim, embed_dim)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
- # 5. 3-Class Classification Head
277
- self.classifier = nn.Sequential(
278
  nn.Linear(embed_dim, embed_dim // 2),
279
  nn.BatchNorm1d(embed_dim // 2),
280
  nn.GELU(),
@@ -282,9 +299,12 @@ class PatchMFFT(nn.Module):
282
  nn.Linear(embed_dim // 2, num_classes),
283
  )
284
 
285
- # 6. Pixel-Level Tampering Localization Head
286
- if enable_localization_head:
287
- self.localization_decoder = TamperingLocalizationDecoder(embed_dim, img_size=img_size)
 
 
 
288
 
289
  def forward(
290
  self,
@@ -292,74 +312,41 @@ class PatchMFFT(nn.Module):
292
  return_mask: bool = False,
293
  return_heatmap: bool = False,
294
  ) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
295
- """
296
- Args:
297
- x: Input image tensor (B, 3, H, W)
298
- return_mask: Whether to return pixel-level localization mask
299
- return_heatmap: Backward compatibility alias for return_mask
300
- Returns:
301
- logits: (B, num_classes)
302
- mask (optional): (B, 1, H, W) probability heatmap of tampered regions
303
- """
304
  B = x.shape[0]
305
-
306
- # 1. Radial Fourier sub-band decomposition
307
  bands = self.decomposer(x)
308
-
309
- # 2. Compute physical spectral energies for FGA gating
310
- energies = [torch.log1p(band.abs().mean(dim=[-3, -2, -1], keepdim=True)) for band in bands]
311
- total_energy = sum(energies) / len(energies) # (B, 1, 1, 1)
312
- energy_prior = total_energy.squeeze(-1).squeeze(-1) # (B, 1)
313
-
314
- # 3. Encode each frequency sub-band through depthwise separable networks
315
- band_feats = []
316
- for k, encoder in enumerate(self.band_encoders):
317
- feat = encoder(bands[k]) # (B, embed_dim, 24, 24)
318
- band_feats.append(feat)
319
-
320
- # High-frequency band features carry dominant tampering cues
321
- high_band_feat = band_feats[-1]
322
-
323
- # 4. Convert spatial feature maps to token sequence for Cross-Attention
324
- # Tokenize across bands: (B, num_bands * N_patches, embed_dim)
325
- band_tokens = []
326
- for feat in band_feats:
327
- tok = feat.flatten(2).permute(0, 2, 1) # (B, 576, embed_dim)
328
- band_tokens.append(tok)
329
-
330
- tokens = torch.cat(band_tokens, dim=1) # (B, K * 576, embed_dim)
331
-
332
- # Apply Cross-Attention Band Fusion modulated by FGA prior
333
- for block in self.fusion_blocks:
334
- tokens = block(
335
- tokens, tokens, tokens,
336
- energy_prior=energy_prior if self.use_fga else None,
337
  )
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
- # 5. Extract fused spatial feature map from token sequence
340
- # Reshape the high-frequency slice back to (B, embed_dim, 24, 24)
341
- n_patches = self.spatial_h * self.spatial_w
342
- fused_high_spatial = tokens[:, -n_patches:, :].permute(0, 2, 1).reshape(
343
- B, self.embed_dim, self.spatial_h, self.spatial_w
344
- )
345
-
346
- # 6. Dual-Branch Spatial-Max Gating (Average + Max pooling)
347
- fused_vector = self.smg_aggregator(fused_high_spatial) # (B, embed_dim)
348
-
349
- # 7. Classification Logits (3-class)
350
- logits = self.classifier(fused_vector)
351
 
352
- # 8. Optional Pixel-Level Tampering Localization Mask
353
  if return_mask or return_heatmap:
354
- if self.enable_localization_head:
355
- mask = self.localization_decoder(fused_high_spatial) # (B, 1, H, W)
356
- else:
357
- # Fallback: normalized spatial energy of high-frequency band
358
- mask = high_band_feat.abs().mean(dim=1, keepdim=True)
359
- mask = F.interpolate(mask, size=(self.img_size, self.img_size), mode="bilinear", align_corners=False)
360
- mask = (mask - mask.amin(dim=[-2, -1], keepdim=True)) / (
361
- mask.amax(dim=[-2, -1], keepdim=True) - mask.amin(dim=[-2, -1], keepdim=True) + 1e-8
362
- )
363
  return logits, mask
364
 
365
  return logits
@@ -372,35 +359,22 @@ def build_patch_mfft(
372
  ablation: Optional[dict] = None,
373
  ) -> PatchMFFT:
374
  """
375
- Model factory for Patch-MFFT variants:
376
- - tiny: ~420K parameters (embed_dim=128, heads=4, depth=2)
377
- - base: ~1.85M parameters (embed_dim=384, heads=6, depth=3)
378
- - large: ~7.10M parameters (embed_dim=768, heads=12, depth=4)
379
  """
380
- v = (variant or "base").lower()
381
- ablation = ablation or {}
382
-
383
- configs = {
384
- "tiny": {"embed_dim": 128, "num_heads": 4, "depth": 2, "num_bands": 3},
385
- "base": {"embed_dim": 384, "num_heads": 6, "depth": 3, "num_bands": 3},
386
- "large": {"embed_dim": 768, "num_heads": 12, "depth": 4, "num_bands": 3},
387
  }
388
-
389
- if v not in configs:
390
- raise ValueError(f"Unknown variant '{variant}'. Choose from: {list(configs.keys())}")
391
-
392
- cfg = configs[v].copy()
393
- if "num_bands" in ablation:
394
- cfg["num_bands"] = ablation["num_bands"]
395
-
396
- model = PatchMFFT(
397
- embed_dim=cfg["embed_dim"],
398
- num_heads=cfg["num_heads"],
399
- depth=cfg["depth"],
400
- num_bands=cfg["num_bands"],
401
  img_size=img_size,
402
  num_classes=num_classes,
403
- use_fga=ablation.get("use_fga", True),
404
- enable_localization_head=True,
405
  )
406
- return model
 
1
  """
2
+ Patch-MFFT: Dual-Branch Radial Frequency Transformer for
3
  Localized Generative Inpainting, Splicing Extortion, and Style Tampering Forensics.
4
 
5
+ Core Architecture (as validated in production swarm training):
6
+ 1. Radial 2D-FFT Annular Sub-band Decomposition (Low, Mid, High concentric bands).
7
+ 2. Multi-Band Depthwise-Separable Feature Encoders (encoders.0, encoders.1, encoders.2).
8
+ 3. Cross-Attention Frequency Fusion (mha + LayerNorm blocks).
9
+ 4. Dual-Branch Spatial-Max Gating (SMG): Unifies Global Average Pooling and Spatial Max-Pooling.
10
+ 5. Dual Task Decoupled Output:
11
+ - 3-Class Triage Categorical Head:
12
+ * 0: Authentic Camera Photography
13
+ * 1: Holistic Generative AI Synthesis
14
+ * 2: Locally Spliced / Inpainted / Tampered
15
+ - Pixel-Level Tampering Localization Decoder:
16
+ * High-resolution tampering anomaly heatmap [0, 1]^{H x W}.
 
 
17
  """
18
 
19
  import math
20
  from typing import Dict, List, Optional, Tuple, Union
21
 
 
22
  import torch
23
  import torch.nn as nn
24
  import torch.nn.functional as F
 
26
 
27
  class RadialFourierDecomposition(nn.Module):
28
  """
29
+ Decomposes an input image into K concentric radial annular frequency bands
30
  in the 2D Fast Fourier Transform (FFT) domain.
 
31
  """
32
 
33
+ def __init__(self, img_size: int = 384, num_bands: int = 3):
34
  super().__init__()
35
  self.img_size = img_size
36
  self.num_bands = num_bands
37
+ configs = (
38
+ [(0.0, 0.15), (0.15, 0.5), (0.5, 1.0)]
39
+ if num_bands == 3
40
+ else [(0.0, 0.125), (0.125, 0.25), (0.25, 0.5), (0.5, 1.0)]
41
+ )
 
 
 
 
 
 
 
 
42
  y = torch.linspace(-1, 1, img_size)
43
  x = torch.linspace(-1, 1, img_size)
44
  gy, gx = torch.meshgrid(y, x, indexing="ij")
45
+ r = torch.sqrt(gx**2 + gy**2) / math.sqrt(2)
 
 
46
  masks = []
47
+ for low_r, high_r in configs:
48
+ m = (r >= low_r) & (r <= high_r if high_r >= 1.0 else r < high_r)
49
+ masks.append(m.float())
50
+ stacked = torch.stack(masks, dim=0)
51
+ norm = stacked.sum(dim=0, keepdim=True).clamp(min=1e-8)
52
+ self.register_buffer("masks", stacked / norm)
 
 
 
 
 
 
53
 
54
  def forward(self, x: torch.Tensor) -> List[torch.Tensor]:
 
 
 
 
 
 
 
 
55
  X_fft = torch.fft.fftshift(torch.fft.fft2(x, norm="ortho"), dim=(-2, -1))
 
56
  bands = []
57
  for k in range(self.num_bands):
58
+ m = self.masks[k].unsqueeze(0).unsqueeze(0)
59
+ bands.append(
60
+ torch.fft.ifft2(
61
+ torch.fft.ifftshift(X_fft * m, dim=(-2, -1)), norm="ortho"
62
+ ).real
63
+ )
64
  return bands
65
 
66
 
67
+ class DCT2DDecomposition(nn.Module):
68
+ def __init__(self, img_size: int = 384, num_bands: int = 3):
 
 
69
  super().__init__()
70
+ self.img_size = img_size
71
+ self.num_bands = num_bands
72
+ N = img_size
73
+ n = torch.arange(N).float()
74
+ k = torch.arange(N).float().unsqueeze(1)
75
+ D = torch.cos(math.pi * (2 * n + 1) * k / (2 * N)) * math.sqrt(2.0 / N)
76
+ D[0, :] = D[0, :] / math.sqrt(2.0)
77
+ self.register_buffer("D", D)
78
+
79
+ u = torch.linspace(0, 1, N)
80
+ v = torch.linspace(0, 1, N)
81
+ gu, gv = torch.meshgrid(u, v, indexing="ij")
82
+ d = (gu + gv) / 2.0
83
+ configs = [(0.0, 0.15), (0.15, 0.5), (0.5, 1.0)]
84
+ masks = []
85
+ for low_d, high_d in configs:
86
+ m = (d >= low_d) & (d <= high_d if high_d >= 1.0 else d < high_d)
87
+ masks.append(m.float())
88
+ stacked = torch.stack(masks, dim=0)
89
+ norm = stacked.sum(dim=0, keepdim=True).clamp(min=1e-8)
90
+ self.register_buffer("masks", stacked / norm)
91
 
92
+ def forward(self, x: torch.Tensor) -> List[torch.Tensor]:
93
+ D = self.D
94
+ xD_T = torch.matmul(x, D.t())
95
+ Y = torch.matmul(D, xD_T)
96
+ bands = []
97
+ for k in range(self.num_bands):
98
+ m = self.masks[k].unsqueeze(0).unsqueeze(0)
99
+ Y_k = Y * m
100
+ D_T_Y = torch.matmul(D.t(), Y_k)
101
+ bands.append(torch.matmul(D_T_Y, D))
102
+ return bands
103
 
 
 
 
 
 
 
 
 
 
104
 
105
+ class HaarDWTDecomposition(nn.Module):
106
+ def __init__(self, in_c: int = 3):
107
+ super().__init__()
108
+ ll = torch.tensor([[1.0, 1.0], [1.0, 1.0]]) * 0.5
109
+ lh = torch.tensor([[-1.0, -1.0], [1.0, 1.0]]) * 0.5
110
+ hl = torch.tensor([[-1.0, 1.0], [-1.0, 1.0]]) * 0.5
111
+ hh = torch.tensor([[1.0, -1.0], [-1.0, 1.0]]) * 0.5
112
+ filters = torch.stack([ll, lh, hl, hh], dim=0).unsqueeze(1)
113
+ self.register_buffer("filters", filters)
114
+ self.in_c = in_c
115
 
116
+ def forward(self, x: torch.Tensor) -> List[torch.Tensor]:
117
+ B, C, H, W = x.shape
118
+ bands = []
119
+ for i in range(4):
120
+ filt = self.filters[i : i + 1].repeat(C, 1, 1, 1)
121
+ y = F.conv2d(x, filt, stride=2, groups=C)
122
+ y_up = F.interpolate(y, size=(H, W), mode="bilinear", align_corners=False)
123
+ bands.append(y_up)
124
+ # 3 bands: Low (LL), Mid (LH + HL), High (HH)
125
+ return [bands[0], (bands[1] + bands[2]) * 0.5, bands[3]]
126
 
 
 
 
 
 
127
 
128
+ class SpatialOnlyDecomposition(nn.Module):
129
+ def __init__(self, num_bands: int = 3):
130
  super().__init__()
131
+ self.num_bands = num_bands
 
 
 
 
 
 
 
132
 
133
+ def forward(self, x: torch.Tensor) -> List[torch.Tensor]:
134
+ return [x] * self.num_bands
 
 
 
135
 
 
 
 
 
136
 
137
+ class DepthwiseSeparableConvBlock(nn.Module):
138
+ """Parameter-efficient depthwise separable convolutional residual block."""
139
 
140
+ def __init__(self, in_c: int, out_c: int, stride: int = 1):
141
+ super().__init__()
142
+ self.dw = nn.Conv2d(in_c, in_c, 3, stride=stride, padding=1, groups=in_c, bias=False)
143
+ self.pw = nn.Conv2d(in_c, out_c, 1, bias=False)
144
+ self.bn1 = nn.BatchNorm2d(out_c)
145
+ self.dw2 = nn.Conv2d(out_c, out_c, 3, 1, 1, groups=out_c, bias=False)
146
+ self.pw2 = nn.Conv2d(out_c, out_c, 1, bias=False)
147
+ self.bn2 = nn.BatchNorm2d(out_c)
148
+ self.act = nn.GELU()
149
+ self.res = (
150
+ nn.Sequential(
151
+ nn.Conv2d(in_c, out_c, 1, stride=stride, bias=False),
152
+ nn.BatchNorm2d(out_c),
153
+ )
154
+ if (stride != 1 or in_c != out_c)
155
+ else nn.Identity()
156
+ )
157
 
158
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
159
+ out = self.act(self.bn1(self.pw(self.dw(x))))
160
+ out = self.bn2(self.pw2(self.dw2(out)))
161
+ return self.act(out + self.res(x))
162
+
163
+
164
+ class UniversalPoolingModule(nn.Module):
165
  """
166
+ Universal feature aggregator supporting Dual-Branch Spatial-Max Gating (SMG),
167
+ Global Average Pooling (GAP), Global Max-Pooling (GMP), and GeM.
 
 
168
  """
169
 
170
+ def __init__(self, in_f: int, out_f: int, mode: str = "smg"):
171
  super().__init__()
172
+ self.mode = mode
173
+ if mode == "smg":
174
+ self.proj = nn.Sequential(
175
+ nn.Linear(in_f * 2, out_f),
176
+ nn.BatchNorm1d(out_f),
177
+ nn.GELU(),
178
+ nn.Dropout(0.1),
179
+ )
180
+ else:
181
+ self.proj = nn.Sequential(
182
+ nn.Linear(in_f, out_f),
183
+ nn.BatchNorm1d(out_f),
184
+ nn.GELU(),
185
+ nn.Dropout(0.1),
186
+ )
187
 
188
+ def forward(self, x: torch.Tensor, cls_token: Optional[torch.Tensor] = None) -> torch.Tensor:
189
+ if self.mode == "smg":
190
+ avg_p = x.mean(dim=[-2, -1])
191
+ max_p = x.amax(dim=[-2, -1])
192
+ return self.proj(torch.cat([avg_p, max_p], dim=-1))
193
+ elif self.mode == "avg":
194
+ return self.proj(x.mean(dim=[-2, -1]))
195
+ elif self.mode == "max":
196
+ return self.proj(x.amax(dim=[-2, -1]))
197
+ elif self.mode == "gem":
198
+ p = 3.0
199
+ gem_p = (x.clamp(min=1e-6) ** p).mean(dim=[-2, -1]) ** (1.0 / p)
200
+ return self.proj(gem_p)
201
+ elif self.mode == "cls":
202
+ return self.proj(cls_token)
203
+ else:
204
+ return self.proj(x.mean(dim=[-2, -1]))
205
 
206
 
207
  class TamperingLocalizationDecoder(nn.Module):
208
  """
209
+ Lightweight convolutional decoder for dense pixel-level tampering segmentation.
210
+ Produces uncompressed 1 x H x W probability maps.
 
211
  """
212
 
213
+ def __init__(self, in_c: int, img_size: int = 384):
214
  super().__init__()
215
+ self.dec = nn.Sequential(
216
+ nn.Conv2d(in_c, 64, 3, padding=1),
 
217
  nn.BatchNorm2d(64),
218
  nn.GELU(),
219
  nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
220
+ nn.Conv2d(64, 32, 3, padding=1),
221
  nn.BatchNorm2d(32),
222
  nn.GELU(),
223
  nn.Upsample(size=(img_size, img_size), mode="bilinear", align_corners=False),
224
+ nn.Conv2d(32, 1, 1),
225
  )
226
 
227
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
228
+ return self.dec(x)
 
229
 
230
 
231
  class PatchMFFT(nn.Module):
232
  """
233
+ Patch-MFFT: Multi-Frequency Fusion Transformer for Image Tampering Forensics.
 
 
 
234
  """
235
 
236
  def __init__(
 
240
  depth: int = 3,
241
  num_bands: int = 3,
242
  img_size: int = 384,
243
+ num_classes: int = 3,
244
+ ablation: Optional[dict] = None,
 
245
  ):
246
  super().__init__()
247
  self.embed_dim = embed_dim
248
  self.num_bands = num_bands
249
  self.img_size = img_size
250
+ self.ablation = ablation or {}
251
+
252
+ t_type = self.ablation.get("transform", "radial_fft")
253
+ if self.ablation.get("spatial_only", False) or t_type == "spatial":
254
+ self.decomposer = SpatialOnlyDecomposition(num_bands)
255
+ elif t_type == "dct":
256
+ self.decomposer = DCT2DDecomposition(img_size, num_bands)
257
+ elif t_type == "dwt":
258
+ self.decomposer = HaarDWTDecomposition(3)
259
+ else:
260
+ self.decomposer = RadialFourierDecomposition(img_size, num_bands)
261
+
262
+ # 1. Sub-band convolutional encoders (stride 2 x 4 stages = downscale 16x)
263
+ self.encoders = nn.ModuleList([
264
+ nn.Sequential(
265
+ DepthwiseSeparableConvBlock(3, 32, 2),
266
+ DepthwiseSeparableConvBlock(32, 64, 2),
267
+ DepthwiseSeparableConvBlock(64, 128, 2),
268
+ DepthwiseSeparableConvBlock(128, embed_dim, 2),
269
+ )
270
+ for _ in range(num_bands)
 
 
 
 
 
271
  ])
272
 
273
+ # 2. Multi-head cross-attention & LayerNorm across bands
274
+ self.mha = nn.ModuleList([
275
+ nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)
276
+ for _ in range(depth)
277
+ ])
278
+ self.norms = nn.ModuleList([nn.LayerNorm(embed_dim) for _ in range(depth)])
279
+
280
+ # 3. Dual-Branch SMG pooling module
281
+ pool_mode = self.ablation.get("pool_mode", "smg")
282
+ if self.ablation.get("no_smg", False):
283
+ pool_mode = "avg"
284
+ self.pool_mode = pool_mode
285
+ self.smg = UniversalPoolingModule(embed_dim, embed_dim, mode=pool_mode)
286
+
287
+ if pool_mode == "cls":
288
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
289
+ nn.init.trunc_normal_(self.cls_token, std=0.02)
290
+ else:
291
+ self.cls_token = None
292
 
293
+ # 4. 3-Class Categorical Head (Authentic, Full AI, Spliced/Tampered)
294
+ self.cls = nn.Sequential(
295
  nn.Linear(embed_dim, embed_dim // 2),
296
  nn.BatchNorm1d(embed_dim // 2),
297
  nn.GELU(),
 
299
  nn.Linear(embed_dim // 2, num_classes),
300
  )
301
 
302
+ # 5. Tampering Localization Decoder
303
+ self.loc = TamperingLocalizationDecoder(embed_dim, img_size)
304
+
305
+ @property
306
+ def pool(self) -> UniversalPoolingModule:
307
+ return self.smg
308
 
309
  def forward(
310
  self,
 
312
  return_mask: bool = False,
313
  return_heatmap: bool = False,
314
  ) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
 
 
 
 
 
 
 
 
 
315
  B = x.shape[0]
 
 
316
  bands = self.decomposer(x)
317
+ feats = [enc(b) for enc, b in zip(self.encoders, bands)]
318
+ tokens = torch.cat([f.flatten(2).permute(0, 2, 1) for f in feats], dim=1)
319
+
320
+ if self.cls_token is not None:
321
+ cls_t = self.cls_token.expand(B, -1, -1)
322
+ tokens = torch.cat([cls_t, tokens], dim=1)
323
+ for mha, norm in zip(self.mha, self.norms):
324
+ attn, _ = mha(tokens, tokens, tokens, need_weights=False)
325
+ tokens = norm(tokens + attn)
326
+ extracted_cls = tokens[:, 0, :]
327
+ n_p = (self.img_size // 16) ** 2
328
+ fused = (
329
+ tokens[:, -n_p:, :]
330
+ .permute(0, 2, 1)
331
+ .reshape(B, self.embed_dim, self.img_size // 16, self.img_size // 16)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  )
333
+ pool = self.smg(fused, cls_token=extracted_cls)
334
+ else:
335
+ for mha, norm in zip(self.mha, self.norms):
336
+ attn, _ = mha(tokens, tokens, tokens, need_weights=False)
337
+ tokens = norm(tokens + attn)
338
+ n_p = (self.img_size // 16) ** 2
339
+ fused = (
340
+ tokens[:, -n_p:, :]
341
+ .permute(0, 2, 1)
342
+ .reshape(B, self.embed_dim, self.img_size // 16, self.img_size // 16)
343
+ )
344
+ pool = self.smg(fused)
345
 
346
+ logits = self.cls(pool)
 
 
 
 
 
 
 
 
 
 
 
347
 
 
348
  if return_mask or return_heatmap:
349
+ mask = self.loc(fused)
 
 
 
 
 
 
 
 
350
  return logits, mask
351
 
352
  return logits
 
359
  ablation: Optional[dict] = None,
360
  ) -> PatchMFFT:
361
  """
362
+ Factory builder for Patch-MFFT model variants:
363
+ - tiny: embed_dim=128, heads=4, depth=2 (563K params)
364
+ - base: embed_dim=384, heads=6, depth=3 (3.28M params)
365
+ - large: embed_dim=768, heads=12, depth=4 (13.94M params)
366
  """
367
+ cfgs = {
368
+ "tiny": {"embed_dim": 128, "heads": 4, "depth": 2},
369
+ "base": {"embed_dim": 384, "heads": 6, "depth": 3},
370
+ "large": {"embed_dim": 768, "heads": 12, "depth": 4},
 
 
 
371
  }
372
+ c = cfgs.get(variant, cfgs["base"])
373
+ return PatchMFFT(
374
+ embed_dim=c["embed_dim"],
375
+ num_heads=c["heads"],
376
+ depth=c["depth"],
 
 
 
 
 
 
 
 
377
  img_size=img_size,
378
  num_classes=num_classes,
379
+ ablation=ablation,
 
380
  )