MuskAI commited on
Commit
acb2ce3
·
verified ·
1 Parent(s): 4a25d3f

Release original LTF v3.3 epoch 20 inference model

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pth filter=lfs diff=lfs merge=lfs -text
2
+ *.pt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
CLIP_LICENSE ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2021 OpenAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
backbones/ViT-L-14.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8cca3fd41ae0c99ba7e8951adf17d267cdb84cd88be6f7c2e0eca1737a03836
3
+ size 932768134
configs/eval.yaml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: v3_3
2
+ backbone: backbones/ViT-L-14.pt
3
+ seed: 42
4
+ epochs: 20
5
+ batch_size: 8
6
+ test_batch_size: 1
7
+ learning_rate: 0.0004
8
+ weight_decay: 0.05
9
+ dropout: 0.1
10
+ jpeg_augment:
11
+ - 60
12
+ - 100
13
+ jpeg_p: 0.1
14
+ blur_p: 0.1
15
+ blur_augment:
16
+ - 0.1
17
+ - 2.5
18
+ resize_augment:
19
+ - 0.25
20
+ - 2
21
+ resize_p: 0.1
22
+ replace_p: 0.1
23
+ replace_ratio:
24
+ - 0.2
25
+ - 0.98
26
+ replace_grid: 16
27
+ num_workers: 8
28
+ train_set: genimage_dr3_rec
29
+ test_set: genimage
30
+ train_set_size: 50000
31
+ test_set_size: null
32
+ use_patch_transformer: false
33
+ linear_classifier: true
34
+ transformer_layers: 0
35
+ transformer_heads: 2
36
+ tile_layers: 1
37
+ tile_heads: 2
38
+ default_train_tiles: 16
39
+ use_global_image: true
40
+ mil_heads: 8
41
+ fusion_hidden: 512
42
+ v2_l1_cross_shuffle_p: 0.9
43
+ v2_l1_replace_ratio_range:
44
+ - 0.5
45
+ - 0.9
46
+ v2_l1_with_replacement_p: 0.5
47
+ v2_l2_cross_shuffle_p: 0.9
48
+ v2_l2_replace_ratio_range:
49
+ - 0.1
50
+ - 0.8
51
+ v2_l2_with_replacement_p: 0.5
52
+ v2_bag_token_shuffle_p: 0.5
53
+ v2_cross_shuffle_min_keep_original: 1
54
+ v2_use_clip_intermediate_for_mil: false
55
+ v3_3_use_pool_gate_fusion: true
56
+ v3_3_pool_gate_hidden: 16
57
+ weights: weights/epoch_20.pth
58
+ eval_amp: false
inference.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Predict real/fake for one image or a directory, without torchrun."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from ltf_final import LTFDetector
10
+ from ltf_final.detector import ROOT, BRANCHES
11
+ from utils.tile_dataset_srm import IMG_EXTENSIONS
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(description=__doc__)
16
+ parser.add_argument('--input', type=Path, required=True, help='Image file or directory, scanned recursively')
17
+ parser.add_argument('--output', type=Path, help='JSONL predictions; stdout is used when omitted')
18
+ parser.add_argument('--config', type=Path, default=ROOT / 'configs/eval.yaml')
19
+ parser.add_argument('--weights', type=Path, help='Override the bundled epoch_20.pth')
20
+ parser.add_argument('--device', default='auto', help='auto, cpu, cuda:0, etc.')
21
+ parser.add_argument('--branch', choices=BRANCHES, default='fusion')
22
+ parser.add_argument('--threshold', type=float, default=.5)
23
+ parser.add_argument('--chunk-size', type=int, default=256, help='CLIP views per forward chunk; tile coverage is preserved')
24
+ parser.add_argument('--threads', type=int, default=4, help='PyTorch CPU thread count')
25
+ args = parser.parse_args()
26
+ if args.threads < 1:
27
+ parser.error('--threads must be positive')
28
+ if not 0 <= args.threshold <= 1:
29
+ parser.error('--threshold must lie in [0,1]')
30
+ source = args.input.expanduser().resolve()
31
+ if source.is_file():
32
+ paths = [source]
33
+ elif source.is_dir():
34
+ paths = sorted(p for p in source.rglob('*') if p.is_file() and p.suffix.lower() in IMG_EXTENSIONS)
35
+ else:
36
+ parser.error(f'Input does not exist: {source}')
37
+ if not paths:
38
+ parser.error('No images found')
39
+ import torch
40
+ torch.set_num_threads(args.threads)
41
+ detector = LTFDetector(args.config, args.weights.resolve() if args.weights else None,
42
+ args.device, args.chunk_size)
43
+ stream, temporary = sys.stdout, None
44
+ if args.output:
45
+ output = args.output.expanduser().resolve()
46
+ output.parent.mkdir(parents=True, exist_ok=True)
47
+ temporary = output.with_name(output.name + f'.{os.getpid()}.tmp')
48
+ stream = temporary.open('w', encoding='utf-8')
49
+ try:
50
+ for path in paths:
51
+ result = detector.predict(path, args.branch, args.threshold)
52
+ stream.write(json.dumps(result, ensure_ascii=False, allow_nan=False) + '\n')
53
+ stream.flush()
54
+ if temporary:
55
+ stream.close()
56
+ os.replace(temporary, output)
57
+ except BaseException:
58
+ if temporary:
59
+ stream.close()
60
+ temporary.unlink(missing_ok=True)
61
+ raise
62
+ print(f'Predicted {len(paths)} image(s); branch={args.branch}; threshold={args.threshold}', file=sys.stderr)
63
+
64
+
65
+ if __name__ == '__main__':
66
+ main()
ltf_final/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Inference API for the released LTF v3.3 model."""
2
+
3
+ from .detector import LTFDetector, load_config, prepare_image
4
+
5
+ __all__ = ['LTFDetector', 'load_config', 'prepare_image']
ltf_final/detector.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline model loading and the original experiment's image preprocessing."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from PIL import Image
9
+ import torch
10
+ import yaml
11
+
12
+ from model.LTF_v3_3 import LTFNetV3_3
13
+ from utils.tile_dataset_srm import clip_transform, crop_image_to_tiles
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ BRANCHES = ('level1', 'level2', 'fusion')
17
+
18
+
19
+ def load_config(path=None):
20
+ path = Path(path) if path else ROOT / 'configs/eval.yaml'
21
+ with path.expanduser().resolve().open(encoding='utf-8') as stream:
22
+ config = yaml.safe_load(stream)
23
+ if not isinstance(config, dict):
24
+ raise ValueError('Configuration must be a YAML mapping')
25
+ return config
26
+
27
+
28
+ def package_path(path):
29
+ path = Path(path).expanduser()
30
+ return path.resolve() if path.is_absolute() else (ROOT / path).resolve()
31
+
32
+
33
+ def model_kwargs(config):
34
+ return dict(
35
+ num_heads=config.get('transformer_heads', 2), num_classes=2,
36
+ num_layers=config.get('transformer_layers', 0), dropout=config.get('dropout', .1),
37
+ tile_heads=config.get('tile_heads', 2), tile_layers=config.get('tile_layers', 1),
38
+ use_global_image=config.get('use_global_image', True),
39
+ use_patch_transformer=config.get('use_patch_transformer', False),
40
+ linear_classifier=config.get('linear_classifier', True),
41
+ mil_heads=config.get('mil_heads', 8), fusion_hidden=config.get('fusion_hidden', 512),
42
+ l1_cross_shuffle_p=float(config.get('v2_l1_cross_shuffle_p', .9)),
43
+ l1_replace_ratio_range=tuple(config.get('v2_l1_replace_ratio_range', [.5, .9])),
44
+ l1_with_replacement_p=float(config.get('v2_l1_with_replacement_p', .5)),
45
+ l2_cross_shuffle_p=float(config.get('v2_l2_cross_shuffle_p', .9)),
46
+ l2_replace_ratio_range=tuple(config.get('v2_l2_replace_ratio_range', [.1, .8])),
47
+ l2_with_replacement_p=float(config.get('v2_l2_with_replacement_p', .5)),
48
+ bag_token_shuffle_p=float(config.get('v2_bag_token_shuffle_p', .5)),
49
+ cross_shuffle_min_keep_original=int(config.get('v2_cross_shuffle_min_keep_original', 1)),
50
+ use_clip_intermediate_for_mil=config.get('v2_use_clip_intermediate_for_mil', False),
51
+ clip_mil_stop_after_block_1based=int(config.get('v2_clip_mil_stop_after_block_1based', 12)),
52
+ pool_gate_hidden=int(config.get('v3_3_pool_gate_hidden', 16)),
53
+ use_pool_gate_fusion=config.get('v3_3_use_pool_gate_fusion', True),
54
+ use_mil_cross_shuffle=config.get('mil_use_cross_shuffle', True),
55
+ )
56
+
57
+
58
+ def read_core_weights(path):
59
+ weights = torch.load(path, map_location='cpu', weights_only=True)
60
+ if not isinstance(weights, dict) or not weights or not all(torch.is_tensor(v) for v in weights.values()):
61
+ raise ValueError('Expected an LTF v3.3 core state_dict, such as epoch_20.pth')
62
+ return weights
63
+
64
+
65
+ def create_model(config, weights=None, device='cpu'):
66
+ import clip
67
+ backbone = package_path(config['backbone'])
68
+ if not backbone.is_file():
69
+ raise FileNotFoundError(f'Bundled CLIP backbone not found: {backbone}')
70
+ # Local checkpoint loading is explicit: this path never downloads a model.
71
+ with contextlib.redirect_stdout(sys.stderr):
72
+ clip_model, _ = clip.load(str(backbone), device='cpu', jit=False)
73
+ model = LTFNetV3_3(clip_model.visual, **model_kwargs(config))
74
+ del clip_model
75
+ if weights is not None:
76
+ model.core.load_state_dict(read_core_weights(package_path(weights)), strict=True)
77
+ return model.to(device)
78
+
79
+
80
+ def prepare_image(path, tile_size=224):
81
+ """Same RGB, tiling, Resize/CenterCrop and CLIP normalization as original eval."""
82
+ path = Path(path).expanduser().resolve()
83
+ with Image.open(path) as source:
84
+ image = source.convert('RGB')
85
+ transform = clip_transform(tile_size, blur_radius=None)
86
+ local_tiles = crop_image_to_tiles(image, tile_size)
87
+ return torch.stack([transform(image)] + [transform(tile) for tile in local_tiles])
88
+
89
+
90
+ class LTFDetector:
91
+ def __init__(self, config=None, weights=None, device='auto', chunk_size=256):
92
+ self.config = dict(config) if isinstance(config, dict) else load_config(config)
93
+ if device == 'auto':
94
+ device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
95
+ self.device = torch.device(device)
96
+ if self.device.type == 'cuda' and not torch.cuda.is_available():
97
+ raise RuntimeError('CUDA is unavailable; use device="cpu" for CPU inference')
98
+ if int(chunk_size) < 1:
99
+ raise ValueError('chunk_size must be positive')
100
+ torch.manual_seed(int(self.config.get('seed', 42)))
101
+ self.weights = package_path(weights or self.config.get('weights', 'weights/epoch_20.pth'))
102
+ self.model = create_model(self.config, self.weights, self.device).eval()
103
+ self.model.eval_chunk_size = int(chunk_size)
104
+
105
+ @torch.inference_mode()
106
+ def predict(self, path, branch='fusion', threshold=.5):
107
+ if branch not in BRANCHES:
108
+ raise ValueError(f'branch must be one of {BRANCHES}')
109
+ if not 0 <= threshold <= 1:
110
+ raise ValueError('threshold must be between zero and one')
111
+ path = Path(path).expanduser().resolve()
112
+ images = prepare_image(path, int(self.model.input_resolution))
113
+ self.model.eval()
114
+ output = self.model(images.unsqueeze(0).to(self.device))
115
+ probabilities = {}
116
+ for name in BRANCHES:
117
+ probability = output[f'logits_{name}'].float().softmax(-1)[0].cpu()
118
+ if not torch.isfinite(probability).all():
119
+ raise ValueError(f'Nonfinite {name} prediction for {path}')
120
+ probabilities[name] = {'real': float(probability[0]), 'fake': float(probability[1])}
121
+ score = probabilities[branch]['fake']
122
+ label = int(score >= threshold)
123
+ return dict(path=str(path), prediction='fake' if label else 'real', label=label,
124
+ branch=branch, threshold=float(threshold), score_fake=score,
125
+ views=int(images.shape[0]), probabilities=probabilities)
mil_module/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone MIL 组件(Cross Shuffle、MIL Head、MILCore 串联),便于拷贝到其它工程。
3
+
4
+ - 训练主仓 Level3 仍使用 ``mil.cross_shuffle`` + ``mil.mil_head``;本目录为 **导出副本**。
5
+ - ``mil_module_export.MILHead`` 与 ``MILCore`` 默认使用 **Beta 头**(``mil_head_beta``),与 ``mil/mil_head.py`` 行为略有差异(见 README)。
6
+ - 需与旧 checkpoint 键完全一致时,请使用 ``MILHeadLegacy``(``mil_head.py``)。
7
+
8
+ Note: ``mil.fusion_module.HighResAggregate`` 使用 ``mil.cross_shuffle_v2``;迁移该路径请单独拷贝 v2。
9
+ """
10
+
11
+ from .cross_shuffle import CrossTokenShuffle
12
+ from .mil_head import MILHead as MILHeadLegacy
13
+ from .mil_head_beta import MILHead
14
+ from .mil_core import (
15
+ CrossShuffleStage,
16
+ CrossShuffleTransMIL,
17
+ MILCore,
18
+ MILHeadStage,
19
+ TransStage,
20
+ )
21
+
22
+ __all__ = [
23
+ "CrossTokenShuffle",
24
+ "MILHead",
25
+ "MILHeadLegacy",
26
+ "MILCore",
27
+ "CrossShuffleStage",
28
+ "TransStage",
29
+ "MILHeadStage",
30
+ "CrossShuffleTransMIL",
31
+ ]
mil_module/cross_shuffle.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from typing import Optional
4
+
5
+ class CrossTokenShuffle(nn.Module):
6
+ """
7
+ CrossTokenShuffle: 跨图像 token 级 shuffle/replace 的增广模块(零 Python for-loop)
8
+
9
+ Donor 规则(与实现一致):
10
+ - 候选 token **仅来自标签为 real(y==0)的图像** 的 token 池。
11
+ - **real 样本**:只能从「其他 real 图像」的 token 中抽取(排除本图)。
12
+ - **fake 样本**:只能从 **real 图像** 的 token 中抽取(fake 图自身无 real token 入池;
13
+ 等价于不允许用 fake token 作 donor)。
14
+
15
+ 替换数量:
16
+ - 先取 K = floor(N * replace_ratio);若 ``min_keep_original > 0``,再令
17
+ ``K = min(K, N - min_keep_original)``,保证至少 ``min_keep_original`` 个位置仍为本图原 token。
18
+ """
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+
23
+ @torch.no_grad()
24
+ def forward(
25
+ self,
26
+ x: torch.Tensor, # (B, N, D)
27
+ y: torch.Tensor, # (B,) 0/1
28
+ replace_ratio: float = 0.5,
29
+ with_replacement: bool = True,
30
+ detach_inserted: bool = True,
31
+ generator: Optional[torch.Generator] = None,
32
+ min_keep_original: int = 0,
33
+ ) -> torch.Tensor:
34
+ """
35
+ 返回与 x 同形状的增广张量,严格遵循候选源与比例规则。
36
+ 备注:无任何 Python 层的 for 循环;内部只用张量广播/索引/聚合算子。
37
+ """
38
+ assert x.dim() == 3, "x must be (B, N, D)"
39
+ B, N, D = x.shape
40
+ if B == 0 or N == 0:
41
+ return x
42
+
43
+ device = x.device
44
+ dtype = x.dtype
45
+
46
+ # 计算每图替换数 K
47
+ K = int(N * replace_ratio)
48
+ mk = int(min_keep_original)
49
+ if mk > 0:
50
+ K = min(K, max(0, N - mk))
51
+ if K <= 0:
52
+ return x # 无需替换
53
+
54
+ # 展平成 token 表 (B*N, D),并构造每个 token 的标签
55
+ x_flat = x.reshape(B * N, D)
56
+ y = y.to(device).view(B) # (B,)
57
+ token_labels = y.repeat_interleave(N) # (B*N,)
58
+
59
+ # 仅「真实图」的 token 可作为 donor(与 allowed_real 语义一致)。
60
+ # 用 M 列紧凑矩阵 (B, M) 代替 (B, B*N):M = N * num_real <= B*N,fake 多时显著省显存/算力。
61
+ real_flat = torch.nonzero(token_labels == 0, as_tuple=False).view(-1)
62
+ M = int(real_flat.numel())
63
+ if M == 0:
64
+ return x.clone()
65
+
66
+ owner = real_flat // N # (M,) donor token 所属 batch 下标(仅 y==0 的图)
67
+ b_ar = torch.arange(B, device=device, dtype=torch.long).unsqueeze(1) # (B, 1)
68
+ rows_fake = (y == 1).unsqueeze(1) # (B, 1)
69
+ # fake 行:donor 仅来自 real 池(任意 real 图的 token);real 行:排除本图 real token
70
+ allowed_m = rows_fake | (owner.unsqueeze(0) != b_ar) # (B, M)
71
+
72
+ has_cand = allowed_m.any(dim=1) # (B,)
73
+
74
+ # —— 选择用于替换的“位置索引”(每图从 [0..N-1] 中采样 K 个,不放回)——
75
+ # 使用随机排序后取前 K(零循环、逐行独立)
76
+ r_pos = torch.rand(B, N, device=device, generator=generator)
77
+ replace_pos_idx = r_pos.argsort(dim=1)[:, :K].long() # [FIX] 显式为 long # (B, K), 每行无放回 K 个位置
78
+
79
+ # —— 选择用于插入的“候选 token 索引”(在扁平 token 表上的行号)——
80
+ # 两种策略:
81
+ # - with_replacement=True:用 torch.multinomial(对 rows_with 有候选的子矩阵);
82
+ # - with_replacement=False:用 Gumbel-TopK(全矩阵),自然无放回。
83
+
84
+ # 预分配容器
85
+ cand_flat_idx = torch.zeros(B, K, device=device, dtype=torch.long)
86
+
87
+ if with_replacement:
88
+ rows_with = has_cand # 只对有候选的行采样
89
+ if rows_with.any():
90
+ weights = allowed_m[rows_with].float() # (Rw, M)
91
+ idx_m = torch.multinomial(
92
+ weights, num_samples=K, replacement=True, generator=generator
93
+ ) # (Rw, K) in [0..M-1]
94
+ cand_flat_idx[rows_with] = real_flat[idx_m]
95
+ # rows_without: 保持 0(占位),后续不会写入(通过行掩码屏蔽)
96
+ else:
97
+ scores = torch.where(
98
+ allowed_m,
99
+ torch.zeros(B, M, device=device, dtype=torch.float32),
100
+ torch.full((B, M), float("-inf"), device=device, dtype=torch.float32),
101
+ )
102
+ U = torch.rand(B, M, device=device, generator=generator).clamp_(1e-6, 1 - 1e-6)
103
+ g = -torch.log(-torch.log(U))
104
+ cand_m = (scores + g).topk(k=K, dim=1).indices # (B, K) in [0..M-1]
105
+ cand_flat_idx = real_flat[cand_m]
106
+
107
+ # 根据 cand_flat_idx 取回 token 内容 (B, K, D)
108
+ inserted_tokens = x_flat.index_select(0, cand_flat_idx.reshape(-1)).reshape(B, K, D)
109
+ if detach_inserted:
110
+ inserted_tokens = inserted_tokens.detach()
111
+
112
+ # 构造输出并写入被替换的位置(仅对有候选的行)
113
+ x_out = x.clone()
114
+ if has_cand.any():
115
+ # -------------------- 修 复 开 始 --------------------
116
+ rows = torch.where(has_cand)[0] # [FIX] 用行号而不是布尔掩码
117
+ pos_idx = replace_pos_idx.index_select(0, rows) # (Rw, K)
118
+ batch_idx = rows.unsqueeze(1).expand(-1, pos_idx.size(1)) # (Rw, K) [FIX] 批次索引与位置索引同形状
119
+ ins = inserted_tokens.index_select(0, rows) # (Rw, K, D)
120
+
121
+ # 稳定的高级索引:两组索引 (batch_idx, pos_idx) 形状一致
122
+ x_out[batch_idx, pos_idx, :] = ins # [FIX] 正确回写,不再广播冲突
123
+ # -------------------- 修 复 结 束 --------------------
124
+
125
+ # 没有候选的行(如 B==1)保持原样
126
+ return x_out.to(dtype=dtype, device=device)
127
+
128
+
129
+ # -------------------------
130
+ # Quick self-check (toy)
131
+ # -------------------------
132
+ if __name__ == "__main__":
133
+ torch.manual_seed(0)
134
+ B, N, D = 256, 256, 1024
135
+ x = torch.arange(B*N*D, dtype=torch.float32).reshape(B, N, D)
136
+ y = torch.tensor([0, 1, 0, 1]*64) # real, fake, real, fake
137
+
138
+ aug = CrossTokenShuffle()
139
+ out = aug(x, y, replace_ratio=0.5, with_replacement=False, detach_inserted=True)
140
+
141
+ # 形状不变
142
+ assert out.shape == x.shape
143
+ # 对 real 行(0/2),只应来自 real 池(行 0 和 2 的 token)且不能用自己:可在调试中检查
144
+ # 对 fake 行(1/3),可来自全池(排除自身)
145
+ print("OK, shape:", out.shape)
mil_module/mil_core.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shuffle -> Trans -> MILHead,由 MILCore 串联;各段为独立 nn.Module 子类。
3
+
4
+ MIL 头默认使用 ``mil_head_beta.MILHead``(attention 模式下支持 ``embed_cls``:
5
+ 先 bag embedding 再分类)。旧版行为见 ``mil_head.py`` 中的 ``MILHead``。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ from typing import Any, Dict, Optional, Set, Tuple, Union
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+ from .cross_shuffle import CrossTokenShuffle
16
+ from .mil_head_beta import MILHead
17
+
18
+
19
+ def _forward_param_names(module: nn.Module) -> Set[str]:
20
+ try:
21
+ return set(inspect.signature(module.forward).parameters.keys())
22
+ except (TypeError, ValueError):
23
+ return set()
24
+
25
+
26
+ class CrossShuffleStage(nn.Module):
27
+ """跨 batch token 替换增广;``use=False`` 时不建内部模块,forward 恒等。"""
28
+
29
+ def __init__(self, use: bool = True) -> None:
30
+ super().__init__()
31
+ self._use = use
32
+ self.op: Optional[CrossTokenShuffle]
33
+ if use:
34
+ self.op = CrossTokenShuffle()
35
+ else:
36
+ self.op = None
37
+
38
+ def forward(
39
+ self,
40
+ x: torch.Tensor,
41
+ y: Optional[torch.Tensor],
42
+ *,
43
+ apply: bool,
44
+ replace_ratio: float = 0.5,
45
+ with_replacement: bool = False,
46
+ detach_inserted: bool = True,
47
+ min_keep_original: int = 0,
48
+ ) -> torch.Tensor:
49
+ if not self._use or not apply or self.op is None or y is None:
50
+ return x
51
+ if y.dim() != 1 or y.shape[0] != x.shape[0]:
52
+ raise ValueError(
53
+ f"y must be 1D [B], got {tuple(y.shape)} for B={x.shape[0]}"
54
+ )
55
+ return self.op(
56
+ x, y,
57
+ replace_ratio=replace_ratio,
58
+ with_replacement=with_replacement,
59
+ detach_inserted=detach_inserted,
60
+ min_keep_original=min_keep_original,
61
+ )
62
+
63
+
64
+ class TransStage(nn.Module):
65
+ """可选中间变换;``core=None`` 时恒等。自动适配 ArtifactSetBranchB(p_shuffle / p_drop / compress_dropped_tokens)或 Encoder(src_key_padding_mask)。
66
+
67
+ 固定 ``[B,N,D]`` 长度、仅用掩码做随机性时:传 ``key_padding_mask``(与 ``core`` 约定一致,一般为 True=padding 忽略),
68
+ 勿依赖 ``p_drop`` 改长度;MIL 侧再用 ``MILHead`` 的 ``mask``(True=有效 token)。
69
+
70
+ 若 ``core`` 返回 ``(x, mil_valid)``(例如压紧 token 后的分支),本阶段原样返回该二元组;
71
+ ``MILCore`` 会将 ``mil_valid`` 作为 MIL 的 ``mask``(与 ``mask`` 参数二选一语义见 ``MILCore.forward``)。
72
+ """
73
+
74
+ def __init__(self, core: Optional[nn.Module] = None) -> None:
75
+ super().__init__()
76
+ self.core = core
77
+ self._params = _forward_param_names(core) if core is not None else set()
78
+
79
+ def forward(
80
+ self,
81
+ x: torch.Tensor,
82
+ *,
83
+ p_shuffle: float = 0.0,
84
+ p_drop: float = 0.0,
85
+ key_padding_mask: Optional[torch.Tensor] = None,
86
+ **kwargs: Any,
87
+ ) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
88
+ if self.core is None:
89
+ return x
90
+ if "p_shuffle" in self._params:
91
+ call_kw: Dict[str, Any] = {}
92
+ for name, val in (
93
+ ("p_shuffle", p_shuffle),
94
+ ("p_drop", p_drop),
95
+ ("key_padding_mask", key_padding_mask),
96
+ ):
97
+ if name in self._params:
98
+ call_kw[name] = val
99
+ for k, v in kwargs.items():
100
+ if k in self._params:
101
+ call_kw[k] = v
102
+ return self.core(x, **call_kw)
103
+ kw2: Dict[str, Any] = {}
104
+ if key_padding_mask is not None and "src_key_padding_mask" in self._params:
105
+ kw2["src_key_padding_mask"] = key_padding_mask
106
+ for k, v in kwargs.items():
107
+ if k in self._params:
108
+ kw2[k] = v
109
+ return self.core(x, **kw2) if kw2 else self.core(x)
110
+
111
+
112
+ class MILHeadStage(nn.Module):
113
+ """对 ``[B,N,D]`` 做实例分类 + bag 池化;内部为 ``mil_head_beta.MILHead``,forward 返回相同结构的 dict。"""
114
+
115
+ def __init__(self, **mil_kw: Any) -> None:
116
+ super().__init__()
117
+ self.head = MILHead(**mil_kw)
118
+
119
+ def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> Dict[str, Any]:
120
+ return self.head(x, mask=mask)
121
+
122
+
123
+ class MILCore(nn.Module):
124
+ """
125
+ ``shuffle`` → ``trans`` → ``head`` 三段组合,等价于原 CrossShuffleTransMIL。
126
+
127
+ **固定 token 数 N(常见导出用法)**:输入始终 ``[B, N, D]``,不需要在 trans 里做 random token drop。
128
+ 随机性可用 **定长掩码**:``trans_key_padding_mask`` 控制 Transformer 忽略的位置;``mask`` 传给 MILHead(True=参与池化的有效 token)。
129
+ 默认 ``allow_trans_token_drop=False``,forward 里传给 trans 的 ``p_drop`` 会被置 0;若要与训练期 L1 的 ``ArtifactSetBranchB`` 完全一致再打开。
130
+
131
+ ��� ``trans`` 返回 ``(x, mil_valid)``(压紧序列场景),``forward`` 会 **忽略** 入参 ``mask`` 并改用 ``mil_valid``;此时请勿再传 ``mask``。
132
+ 其它传给中间模块的参数可通过 ``trans_extra`` 传入(如 ``compress_dropped_tokens=True``)。
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ d_in: int,
138
+ num_classes: int = 2,
139
+ mil_head_pooling: str = "attn",
140
+ mil_head_kwargs: Optional[Dict[str, Any]] = None,
141
+ *,
142
+ use_cross_shuffle: bool = True,
143
+ trans: Optional[nn.Module] = None,
144
+ allow_trans_token_drop: bool = False,
145
+ ) -> None:
146
+ super().__init__()
147
+ self.allow_trans_token_drop = bool(allow_trans_token_drop)
148
+ self.shuffle = CrossShuffleStage(use=use_cross_shuffle)
149
+ self.trans = TransStage(core=trans)
150
+ mil_kw: Dict[str, Any] = {"d_in": d_in, "num_classes": num_classes, "pooling": mil_head_pooling}
151
+ if mil_head_kwargs:
152
+ mil_kw.update(mil_head_kwargs)
153
+ self.head = MILHeadStage(**mil_kw)
154
+
155
+ def forward(
156
+ self,
157
+ tokens: torch.Tensor,
158
+ y: Optional[torch.Tensor] = None,
159
+ mask: Optional[torch.Tensor] = None,
160
+ *,
161
+ do_cross_shuffle: bool = False,
162
+ replace_ratio: float = 0.5,
163
+ with_replacement: bool = False,
164
+ detach_inserted: bool = True,
165
+ cross_shuffle_min_keep: int = 0,
166
+ p_shuffle: float = 0.0,
167
+ p_drop: float = 0.0,
168
+ trans_key_padding_mask: Optional[torch.Tensor] = None,
169
+ trans_extra: Optional[Dict[str, Any]] = None,
170
+ return_dict: bool = False,
171
+ ):
172
+ x = self.shuffle(
173
+ tokens, y,
174
+ apply=do_cross_shuffle,
175
+ replace_ratio=replace_ratio,
176
+ with_replacement=with_replacement,
177
+ detach_inserted=detach_inserted,
178
+ min_keep_original=cross_shuffle_min_keep,
179
+ )
180
+ trans_p_drop = float(p_drop) if self.allow_trans_token_drop else 0.0
181
+ tex = dict(trans_extra or {})
182
+ trans_out = self.trans(
183
+ x,
184
+ p_shuffle=p_shuffle,
185
+ p_drop=trans_p_drop,
186
+ key_padding_mask=trans_key_padding_mask,
187
+ **tex,
188
+ )
189
+ if isinstance(trans_out, tuple) and len(trans_out) == 2:
190
+ x, trans_mil_mask = trans_out
191
+ if mask is not None:
192
+ raise ValueError(
193
+ "MILCore: `trans` returned (x, mil_valid); omit `mask` and use the trans-stage mask."
194
+ )
195
+ mask = trans_mil_mask
196
+ else:
197
+ x = trans_out # type: ignore[assignment]
198
+ if mask is not None and mask.shape != x.shape[:2]:
199
+ raise ValueError(
200
+ f"mask must be [B,N], got {tuple(mask.shape)} vs x {tuple(x.shape[:2])}"
201
+ )
202
+ mil_out = self.head(x, mask=mask)
203
+ if not return_dict:
204
+ return mil_out["bag_logits"]
205
+ return {"bag_logits": mil_out["bag_logits"], "mil_head": mil_out, "features": x}
206
+
207
+ def forward_logits(
208
+ self,
209
+ tokens: torch.Tensor,
210
+ y: Optional[torch.Tensor] = None,
211
+ mask: Optional[torch.Tensor] = None,
212
+ **kwargs: Any,
213
+ ) -> torch.Tensor:
214
+ extra = dict(kwargs)
215
+ extra.pop("return_dict", None)
216
+ return self.forward(tokens, y, mask, return_dict=False, **extra)
217
+
218
+
219
+ CrossShuffleTransMIL = MILCore
mil_module/mil_head.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+
6
+ class MILHead(nn.Module):
7
+ """
8
+ MIL Head supporting max / LogSumExp / top-k / attention-gated pooling.
9
+
10
+ Args:
11
+ d_in (int): token feature dim (ViT最后一层的D)
12
+ num_classes (int): 类别数;二分类用1更自然(配合 BCEWithLogits)
13
+ pooling (str): 'max' | 'lsep' | 'topk' | 'attn'
14
+ lsep_temp (float): LogSumExp 的温度 (softmax 温度的倒数); 有效时 bag = logsumexp(logits/T)
15
+ lsep_norm (bool): 是否减去 log(N) 做长度归一
16
+ topk (int | None): 固定 k;若为 None 则使用 ratio
17
+ topk_ratio (float): 使用比例选 top-k,例如 0.05 表示前 5%
18
+ attn_hidden (int): gated-attention 的隐层维度 L
19
+ attn_dropout (float): gated-attention 的 dropout
20
+ """
21
+ def __init__(
22
+ self,
23
+ d_in: int = 1024,
24
+ num_classes: int = 2,
25
+ pooling: str = "lsep",
26
+ lsep_temp: float = 1.0,
27
+ lsep_norm: bool = True,
28
+ topk: int = None,
29
+ topk_ratio: float = 0.1,
30
+ attn_hidden: int = 128,
31
+ attn_dropout: float = 0.0,
32
+ ):
33
+ super().__init__()
34
+ self.num_classes = num_classes
35
+ self.pooling = pooling.lower()
36
+ self.lsep_temp = float(lsep_temp)
37
+ self.lsep_norm = bool(lsep_norm)
38
+ self.topk = topk
39
+ self.topk_ratio = float(topk_ratio)
40
+
41
+ # per-token classifier (shared across tokens)
42
+ self.classifier = nn.Linear(d_in, num_classes)
43
+
44
+ # components for gated-attention MIL (Ilse et al., 2018)
45
+ if self.pooling == "attn":
46
+ print(f'Using gated-attention pooling with hidden size {attn_hidden}')
47
+ self.attn_V = nn.Linear(d_in, attn_hidden, bias=True)
48
+ self.attn_U = nn.Linear(d_in, attn_hidden, bias=True)
49
+ self.attn_w = nn.Linear(attn_hidden, 1, bias=False)
50
+ self.attn_drop = nn.Dropout(attn_dropout)
51
+
52
+ @staticmethod
53
+ def _masked_fill_min(x, mask, fill_value=None):
54
+ """Utility: set masked positions to a very small number before max/topk."""
55
+ if mask is None:
56
+ return x
57
+ if fill_value is None:
58
+ fill_value = torch.finfo(x.dtype).min
59
+ return x.masked_fill(~mask, fill_value)
60
+
61
+ def _pool_max(self, logits, mask=None):
62
+ # logits: [B, N, C] -> [B, C]
63
+ logits = self._masked_fill_min(logits, mask.unsqueeze(-1) if mask is not None else None)
64
+ bag = logits.max(dim=1).values
65
+ return bag
66
+
67
+ def _pool_lsep(self, logits, mask=None):
68
+ # logits: [B, N, C] -> [B, C]
69
+ # temperature T: divide logits by T, smaller T -> sharper
70
+ z = logits / self.lsep_temp
71
+ if mask is not None:
72
+ # 将被mask掉的设为 -inf,避免影响 logsumexp
73
+ neg_inf = torch.finfo(z.dtype).min
74
+ z = z.masked_fill(~mask.unsqueeze(-1), neg_inf)
75
+ bag = torch.logsumexp(z, dim=1)
76
+ if self.lsep_norm:
77
+ # 归一:减去 log(有效实例数)
78
+ if mask is None:
79
+ n = logits.size(1)
80
+ bag = bag - math.log(max(n, 1))
81
+ else:
82
+ n_eff = mask.sum(dim=1).clamp_min(1).unsqueeze(-1) # [B,1]
83
+ bag = bag - n_eff.log()
84
+ return bag
85
+
86
+ def _pool_topk(self, logits, mask=None):
87
+ # logits: [B, N, C] -> [B, C]
88
+ B, N, C = logits.shape
89
+ if mask is not None:
90
+ logits = self._masked_fill_min(logits, mask.unsqueeze(-1))
91
+ n_eff = mask.sum(dim=1) # [B]
92
+ else:
93
+ n_eff = logits.new_full((B,), N)
94
+
95
+ k = self.topk
96
+ if k is None:
97
+ # ratio -> at least 1
98
+ k = torch.clamp((n_eff.float() * self.topk_ratio).long(), min=1)
99
+ else:
100
+ k = torch.clamp(torch.full_like(n_eff, k), min=1, max=N)
101
+
102
+ # 为了支持不同 batch 内不同 k,这里用排序 + gather
103
+ # scores: [B, N, C] -> 先在 N 维排序,取 top-k 再平均
104
+ # 我们对每个类独立做 topk:展开为 [B, C, N]
105
+ scores = logits.transpose(1, 2) # [B, C, N]
106
+ # 对每个 (B,C) 维度上选择前 k 个
107
+ topk_vals = []
108
+ for b in range(B):
109
+ # 如果某些样本 k > 有效实例数,用有效实例数
110
+ kb = int(min(int(k[b].item()), int(n_eff[b].item())))
111
+ vals, _ = scores[b].topk(kb, dim=-1)
112
+ topk_vals.append(vals.mean(dim=-1, keepdim=True)) # [C,1]
113
+ bag = torch.cat(topk_vals, dim=1).transpose(0,1).contiguous().squeeze(1) # [B, C]
114
+ return bag
115
+ # v1
116
+ # def _pool_attn(self, feats, logits, mask=None):
117
+ # """
118
+ # Gated-attention pooling:
119
+ # a_i ∝ w^T ( tanh(V h_i) ⊙ sigmoid(U h_i) )
120
+ # bag = Σ a_i * logits_i
121
+ # feats: [B, N, D]
122
+ # logits: [B, N, C]
123
+ # mask: [B, N] (True for valid)
124
+ # Returns:
125
+ # bag_logits: [B, C]
126
+ # attn: [B, N, 1]
127
+ # """
128
+ # Vh = self.attn_V(feats) # [B, N, L]
129
+ # Uh = self.attn_U(feats) # [B, N, L]
130
+ # gate = torch.tanh(Vh) * torch.sigmoid(Uh)
131
+ # gate = self.attn_drop(gate)
132
+ # attn = self.attn_w(gate) # [B, N, 1]
133
+
134
+ # if mask is not None:
135
+ # neg_inf = torch.finfo(attn.dtype).min
136
+ # attn = attn.masked_fill(~mask.unsqueeze(-1), neg_inf)
137
+
138
+ # attn = F.softmax(attn, dim=1) # [B, N, 1]
139
+ # bag = (attn * logits).sum(dim=1) # [B, C]
140
+ # return bag, attn
141
+ # v2 新增了返回加权之后的embedding
142
+ def _pool_attn(self, feats, logits, mask=None):
143
+ """
144
+ Gated-attention pooling:
145
+ a_i ∝ w^T ( tanh(V h_i) ⊙ sigmoid(U h_i) )
146
+ bag_logits = Σ a_i * logits_i
147
+ bag_embed = Σ a_i * h_i
148
+ feats: [B, N, D]
149
+ logits: [B, N, C]
150
+ mask: [B, N] (True for valid)
151
+ Returns:
152
+ bag_logits: [B, C]
153
+ attn: [B, N, 1]
154
+ bag_embed: [B, D] (mix之后的embedding)
155
+ """
156
+ Vh = self.attn_V(feats) # [B, N, L]
157
+ Uh = self.attn_U(feats) # [B, N, L]
158
+ gate = torch.tanh(Vh) * torch.sigmoid(Uh)
159
+ gate = self.attn_drop(gate)
160
+ attn = self.attn_w(gate) # [B, N, 1]
161
+
162
+ if mask is not None:
163
+ neg_inf = torch.finfo(attn.dtype).min
164
+ attn = attn.masked_fill(~mask.unsqueeze(-1), neg_inf)
165
+
166
+ attn = F.softmax(attn, dim=1) # [B, N, 1]
167
+
168
+ # 原来的 bag-level logits
169
+ bag_logits = (attn * logits).sum(dim=1) # [B, C]
170
+
171
+ # 新增:mix 之后的 bag-level embedding
172
+ bag_embed = (attn * feats).sum(dim=1) # [B, D]
173
+
174
+ return bag_logits, attn, bag_embed
175
+ def forward(self, feats, mask=None):
176
+ """
177
+ Args:
178
+ feats: [B, N, D] — ViT最后一层每patch的token特征
179
+ mask: [B, N] — 可选,True表示有效token(padding用)
180
+ Returns:
181
+ dict{
182
+ 'inst_logits': [B, N, C],
183
+ 'bag_logits': [B, C],
184
+ 'attn': [B, N, 1] or None
185
+ }
186
+ """
187
+ inst_logits = self.classifier(feats) # [B, N, C]
188
+
189
+ if self.pooling == "max":
190
+ bag_logits = self._pool_max(inst_logits, mask)
191
+ attn = None
192
+ elif self.pooling == "lsep":
193
+ bag_logits = self._pool_lsep(inst_logits, mask)
194
+ attn = None
195
+ elif self.pooling == "topk":
196
+ bag_logits = self._pool_topk(inst_logits, mask)
197
+ attn = None
198
+ elif self.pooling == "attn":
199
+ # v1
200
+ # bag_logits, attn = self._pool_attn(feats, inst_logits, mask)
201
+ # v2
202
+ bag_logits, attn, bag_embed = self._pool_attn(feats, inst_logits, mask) # 返回bag_embed是为了后续的聚合
203
+ else:
204
+ raise ValueError(f"Unknown pooling mode: {self.pooling}")
205
+
206
+ return {
207
+ "inst_logits": inst_logits,
208
+ "bag_logits": bag_logits,
209
+ "attn": attn,
210
+ "bag_embed": bag_embed if self.pooling == "attn" else None,
211
+ }
mil_module/mil_head_beta.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+
6
+
7
+ class MILHead(nn.Module):
8
+ """
9
+ MIL Head supporting max / LogSumExp / top-k / attention-gated pooling.
10
+
11
+ Args:
12
+ d_in (int): token feature dim
13
+ num_classes (int): number of classes
14
+ pooling (str): 'max' | 'lsep' | 'topk' | 'attn'
15
+ lsep_temp (float): temperature for LogSumExp pooling
16
+ lsep_norm (bool): whether to subtract log(N_eff)
17
+ topk (int | None): fixed k for top-k pooling
18
+ topk_ratio (float): ratio for top-k if topk is None
19
+ attn_hidden (int): hidden dim for gated-attention
20
+ attn_dropout (float): dropout for gated-attention
21
+ attn_bag_mode (str): only used when pooling='attn'
22
+ - 'logit_pool': bag_logits = sum(attn * inst_logits)
23
+ - 'embed_cls' : bag_embed = sum(attn * feats), then bag_logits = bag_classifier(bag_embed)
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ d_in: int = 1024,
29
+ num_classes: int = 2,
30
+ pooling: str = "lsep",
31
+ lsep_temp: float = 1.0,
32
+ lsep_norm: bool = True,
33
+ topk: int = None,
34
+ topk_ratio: float = 0.1,
35
+ attn_hidden: int = 128,
36
+ attn_dropout: float = 0.0,
37
+ attn_bag_mode: str = "embed_cls",
38
+ ):
39
+ super().__init__()
40
+ self.num_classes = num_classes
41
+ self.pooling = pooling.lower()
42
+ self.lsep_temp = float(lsep_temp)
43
+ self.lsep_norm = bool(lsep_norm)
44
+ self.topk = topk
45
+ self.topk_ratio = float(topk_ratio)
46
+ self.attn_bag_mode = attn_bag_mode.lower()
47
+
48
+ if self.pooling not in {"max", "lsep", "topk", "attn"}:
49
+ raise ValueError(f"Unknown pooling mode: {self.pooling}")
50
+
51
+ if self.attn_bag_mode not in {"logit_pool", "embed_cls"}:
52
+ raise ValueError(f"Unknown attn_bag_mode: {self.attn_bag_mode}")
53
+
54
+ # Instance-level classifier: shared across tokens
55
+ self.classifier = nn.Linear(d_in, num_classes)
56
+
57
+ # Attention pooling modules
58
+ if self.pooling == "attn":
59
+ print(
60
+ f"Using gated-attention pooling with hidden size {attn_hidden}, "
61
+ f"bag mode = {self.attn_bag_mode}"
62
+ )
63
+ self.attn_V = nn.Linear(d_in, attn_hidden, bias=True)
64
+ self.attn_U = nn.Linear(d_in, attn_hidden, bias=True)
65
+ self.attn_w = nn.Linear(attn_hidden, 1, bias=False)
66
+ self.attn_drop = nn.Dropout(attn_dropout)
67
+
68
+ # Decoupled bag classifier
69
+ if self.attn_bag_mode == "embed_cls":
70
+ self.bag_classifier = nn.Linear(d_in, num_classes)
71
+ else:
72
+ self.bag_classifier = None
73
+ else:
74
+ self.bag_classifier = None
75
+
76
+ @staticmethod
77
+ def _masked_fill_min(x, mask, fill_value=None):
78
+ """Set masked positions to a very small number before max/topk."""
79
+ if mask is None:
80
+ return x
81
+ if fill_value is None:
82
+ fill_value = torch.finfo(x.dtype).min
83
+ return x.masked_fill(~mask, fill_value)
84
+
85
+ def _pool_max(self, logits, mask=None):
86
+ logits = self._masked_fill_min(
87
+ logits, mask.unsqueeze(-1) if mask is not None else None
88
+ )
89
+ bag = logits.max(dim=1).values
90
+ return bag
91
+
92
+ def _pool_lsep(self, logits, mask=None):
93
+ z = logits / self.lsep_temp
94
+ if mask is not None:
95
+ neg_inf = torch.finfo(z.dtype).min
96
+ z = z.masked_fill(~mask.unsqueeze(-1), neg_inf)
97
+
98
+ bag = torch.logsumexp(z, dim=1)
99
+
100
+ if self.lsep_norm:
101
+ if mask is None:
102
+ n = logits.size(1)
103
+ bag = bag - math.log(max(n, 1))
104
+ else:
105
+ n_eff = mask.sum(dim=1).clamp_min(1).unsqueeze(-1)
106
+ bag = bag - n_eff.log()
107
+
108
+ return bag
109
+
110
+ def _pool_topk(self, logits, mask=None):
111
+ B, N, C = logits.shape
112
+
113
+ if mask is not None:
114
+ logits = self._masked_fill_min(logits, mask.unsqueeze(-1))
115
+ n_eff = mask.sum(dim=1)
116
+ else:
117
+ n_eff = logits.new_full((B,), N, dtype=torch.long)
118
+
119
+ k = self.topk
120
+ if k is None:
121
+ k = torch.clamp((n_eff.float() * self.topk_ratio).long(), min=1)
122
+ else:
123
+ k = torch.clamp(torch.full_like(n_eff, k), min=1, max=N)
124
+
125
+ scores = logits.transpose(1, 2) # [B, C, N]
126
+ topk_vals = []
127
+ for b in range(B):
128
+ kb = int(min(int(k[b].item()), int(n_eff[b].item())))
129
+ vals, _ = scores[b].topk(kb, dim=-1) # [C, kb]
130
+ topk_vals.append(vals.mean(dim=-1)) # [C]
131
+
132
+ bag = torch.stack(topk_vals, dim=0) # [B, C]
133
+ return bag
134
+
135
+ def _compute_attn(self, feats, mask=None):
136
+ """
137
+ feats: [B, N, D]
138
+ mask : [B, N]
139
+ returns:
140
+ attn: [B, N, 1]
141
+ """
142
+ Vh = self.attn_V(feats) # [B, N, L]
143
+ Uh = self.attn_U(feats) # [B, N, L]
144
+ gate = torch.tanh(Vh) * torch.sigmoid(Uh)
145
+ gate = self.attn_drop(gate)
146
+ attn = self.attn_w(gate) # [B, N, 1]
147
+
148
+ if mask is not None:
149
+ neg_inf = torch.finfo(attn.dtype).min
150
+ attn = attn.masked_fill(~mask.unsqueeze(-1), neg_inf)
151
+
152
+ attn = F.softmax(attn, dim=1)
153
+ return attn
154
+
155
+ def _pool_attn(self, feats, logits, mask=None):
156
+ """
157
+ Returns:
158
+ bag_logits: [B, C]
159
+ attn: [B, N, 1]
160
+ bag_embed: [B, D]
161
+ """
162
+ attn = self._compute_attn(feats, mask) # [B, N, 1]
163
+ bag_embed = (attn * feats).sum(dim=1) # [B, D]
164
+
165
+ if self.attn_bag_mode == "logit_pool":
166
+ bag_logits = (attn * logits).sum(dim=1) # [B, C]
167
+ elif self.attn_bag_mode == "embed_cls":
168
+ bag_logits = self.bag_classifier(bag_embed) # [B, C]
169
+ else:
170
+ raise ValueError(f"Unknown attn_bag_mode: {self.attn_bag_mode}")
171
+
172
+ return bag_logits, attn, bag_embed
173
+
174
+ def forward(self, feats, mask=None):
175
+ """
176
+ Args:
177
+ feats: [B, N, D]
178
+ mask: [B, N], True means valid token
179
+ Returns:
180
+ dict{
181
+ 'inst_logits': [B, N, C],
182
+ 'bag_logits': [B, C],
183
+ 'attn': [B, N, 1] or None,
184
+ 'bag_embed': [B, D] or None
185
+ }
186
+ """
187
+ inst_logits = self.classifier(feats) # [B, N, C]
188
+
189
+ attn = None
190
+ bag_embed = None
191
+
192
+ if self.pooling == "max":
193
+ bag_logits = self._pool_max(inst_logits, mask)
194
+
195
+ elif self.pooling == "lsep":
196
+ bag_logits = self._pool_lsep(inst_logits, mask)
197
+
198
+ elif self.pooling == "topk":
199
+ bag_logits = self._pool_topk(inst_logits, mask)
200
+
201
+ elif self.pooling == "attn":
202
+ bag_logits, attn, bag_embed = self._pool_attn(feats, inst_logits, mask)
203
+
204
+ else:
205
+ raise ValueError(f"Unknown pooling mode: {self.pooling}")
206
+
207
+ return {
208
+ "inst_logits": inst_logits,
209
+ "bag_logits": bag_logits,
210
+ "attn": attn,
211
+ "bag_embed": bag_embed,
212
+ }
model/LTF.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+ class LTFCore(nn.Module):
5
+ """
6
+ Core module.
7
+ Input: x (B, N, T, D)
8
+ Output: logits (B, num_classes), token_scores (B, N, T-1, 1), jpeg_score (B, 1)
9
+ """
10
+ def __init__(
11
+ self,
12
+ embed_dim=1024,
13
+ num_heads=8,
14
+ num_classes=2,
15
+ num_layers=1,
16
+ dropout=0.1,
17
+ tile_heads=2,
18
+ tile_layers=2,
19
+ use_global_image=True,
20
+ use_patch_transformer=True,
21
+ linear_classifier=False,
22
+ ):
23
+ super().__init__()
24
+ self.use_global_image = use_global_image
25
+ self.use_patch_transformer = use_patch_transformer
26
+ # Learnable CLS for tile-level aggregation
27
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
28
+ # Tile-level encoder (across tiles)
29
+ encoder_layer = nn.TransformerEncoderLayer(
30
+ d_model=embed_dim,
31
+ dropout=dropout,
32
+ nhead=tile_heads,
33
+ batch_first=True
34
+ )
35
+ self.tile_transformer = nn.TransformerEncoder(encoder_layer, num_layers=tile_layers)
36
+ # Token-level encoder (within each tile); v1_7 等可关闭以降低容量
37
+ if use_patch_transformer:
38
+ token_layer = nn.TransformerEncoderLayer(
39
+ d_model=embed_dim,
40
+ dropout=dropout,
41
+ nhead=num_heads,
42
+ batch_first=True
43
+ )
44
+ self.patch_transformer = nn.TransformerEncoder(token_layer, num_layers=num_layers)
45
+ else:
46
+ self.patch_transformer = None
47
+ # Image-level classifier: with global (main tile + aggregated tiles) or only aggregated tiles (ablation)
48
+ classifier_in_dim = embed_dim * 2 if use_global_image else embed_dim
49
+ if linear_classifier:
50
+ self.classifier = nn.Linear(classifier_in_dim, num_classes)
51
+ else:
52
+ self.classifier = nn.Sequential(
53
+ nn.Linear(classifier_in_dim, 512),
54
+ nn.ReLU(),
55
+ nn.Dropout(p=dropout),
56
+ nn.Linear(512, num_classes)
57
+ )
58
+ # Per-token head (for non-CLS tokens)
59
+ self.patch_classifier = nn.Sequential(
60
+ nn.Linear(embed_dim, 512),
61
+ nn.ReLU(),
62
+ nn.Dropout(p=dropout),
63
+ nn.Linear(512, 1)
64
+ )
65
+ # Scalar head (e.g., JPEG-related score)
66
+ self.jpeg_estimate = nn.Sequential(
67
+ nn.Linear(embed_dim, 256),
68
+ nn.ReLU(),
69
+ nn.Linear(256, 128),
70
+ nn.ReLU(),
71
+ nn.Linear(128, 128),
72
+ nn.ReLU(),
73
+ nn.Linear(128, 1),
74
+ )
75
+
76
+ nn.init.trunc_normal_(self.cls_token, std=0.02)
77
+
78
+ def forward(self, x):
79
+ """
80
+ x: (B, N, T, D)
81
+ returns: (B, num_classes), (B, N, T-1, 1), (B, 1)
82
+ """
83
+ B_2, N_2, T_2, D_2 = x.shape
84
+
85
+ # Token-level encoding per tile (optional)
86
+ x = x.reshape(B_2*N_2, T_2, D_2) # [B*N, T, D]
87
+ if self.patch_transformer is not None:
88
+ x = self.patch_transformer(x)
89
+ x = x.reshape(B_2, N_2, T_2, D_2)
90
+
91
+ # Split CLS vs non-CLS tokens
92
+ new_cls = x[:, :, 0, :]
93
+ new_x = x[:, :, 1:, :]
94
+ # Per-token head on non-CLS tokens
95
+ x_flat = new_x.reshape(-1, D_2) # [B*N*T, D]
96
+ x_result = self.patch_classifier(x_flat) # [B*N*(T-1), 1]
97
+ x_result = x_result.reshape(B_2, N_2, T_2 - 1, 1) # [B, N, T-1, 1]
98
+
99
+ main_feat = new_cls[:, 0] # (B, D)
100
+ tile_feats = new_cls[:, 1:] # (B, N-1, D)
101
+ # print(main_feat.shape, tile_feats.shape)
102
+
103
+ # Tile-level aggregation with a learnable CLS
104
+ cls_token = self.cls_token.expand(B_2, 1, -1) # (B, 1, D)
105
+ feature_tile = torch.cat([cls_token, tile_feats], dim=1) # (B, N, D)
106
+
107
+ feature_encoded = self.tile_transformer(feature_tile) # (B, N, D)
108
+ cls_out = feature_encoded[:, 0] # (B, D)
109
+
110
+ # Image-level classification: with or without global (main tile) feature (ablation v1_1)
111
+ if self.use_global_image:
112
+ concat_feat = torch.cat([main_feat, cls_out], dim=1) # (B, 2D)
113
+ else:
114
+ concat_feat = cls_out # (B, D)
115
+ return self.classifier(concat_feat), x_result, torch.sigmoid(self.jpeg_estimate(cls_out)) # (B, num_classes) (B)
116
+
117
+ class LTFNet(nn.Module):
118
+ """
119
+ Wrapper with a frozen visual backbone (e.g., CLIP visual) + LTFCore.
120
+ Input: images x (B, N, 3, H, W)
121
+ Output: same as LTFCore
122
+ """
123
+ def __init__(
124
+ self,
125
+ clip_visual,
126
+ num_heads=8,
127
+ num_classes=2,
128
+ num_layers=1,
129
+ dropout=0.2,
130
+ tile_heads=2,
131
+ tile_layers=2,
132
+ use_global_image=True,
133
+ use_patch_transformer=True,
134
+ linear_classifier=False,
135
+ ):
136
+ super().__init__()
137
+ self.visual = clip_visual
138
+ self.dtype = self.visual.conv1.weight.dtype
139
+
140
+ for p in self.visual.parameters():
141
+ p.requires_grad = False
142
+ self.visual.eval()
143
+
144
+ self.input_resolution = self.visual.input_resolution
145
+ # Token feature dim = transformer hidden size (ViT-L -> 1024). Use conv1 out_channels;
146
+ # do NOT use output_dim which is the projection-head dim (often 768) and would cause
147
+ # "expecting 768 but got 1024" in patch_transformer.
148
+ self.embed_dim = self.visual.conv1.weight.shape[0]
149
+
150
+ self.core = LTFCore(
151
+ embed_dim=self.embed_dim,
152
+ num_heads=num_heads,
153
+ num_classes=num_classes,
154
+ num_layers=num_layers,
155
+ dropout=dropout,
156
+ tile_heads=tile_heads,
157
+ tile_layers=tile_layers,
158
+ use_global_image=use_global_image,
159
+ use_patch_transformer=use_patch_transformer,
160
+ linear_classifier=linear_classifier,
161
+ )
162
+
163
+ def get_tokens(self, x) -> torch.Tensor:
164
+ """
165
+ Extract token sequence (with CLS) from the visual backbone.
166
+ Assumes CLIP ViT structure: conv1 -> patch embed -> class_embedding + pos_embed -> ln_pre -> transformer.
167
+ x: (B, 3, H, W) -> tokens: (B, T, D)
168
+ """
169
+ x = x.type(self.dtype)
170
+ x = self.visual.conv1(x) # [B, C, H, W]
171
+ x = x.reshape(x.shape[0], x.shape[1], -1) # [B, C, HW]
172
+ x = x.permute(0, 2, 1) # [B, HW, C]
173
+ cls = self.visual.class_embedding.to(x.dtype) + torch.zeros(x.shape[0], 1, x.shape[2], dtype=x.dtype, device=x.device)
174
+ x = torch.cat([cls, x], dim=1) # [B, 1+HW, C]
175
+ x = x + self.visual.positional_embedding.to(x.dtype)
176
+ x = self.visual.ln_pre(x)
177
+ x = x.permute(1, 0, 2) # [sequence_len, B, C]
178
+ x = self.visual.transformer(x)
179
+ x = x.permute(1, 0, 2) # [B, sequence_len, C]
180
+ return x
181
+
182
+ def encode_image(self, image) -> torch.Tensor:
183
+ # image: (B, 3, H, W) -> feature: (B, D)
184
+ return self.visual(image.type(self.dtype))
185
+
186
+ def forward(self, x: torch.Tensor, chunk_size=256):
187
+ # Tensor[B, 5, 3, 224, 224]
188
+ """
189
+ x: Tensor of shape (B, N, 3, H, W)
190
+ chunk_size: number of tiles to pass through the visual backbone per batch. Same tiles are
191
+ computed and concatenated; only speed/memory change, no effect on outputs.
192
+ Eval can set model.eval_chunk_size (e.g. 512) in config for faster run.
193
+ """
194
+ chunk_size = getattr(self, "eval_chunk_size", chunk_size)
195
+ B, N, C, H, W = x.shape
196
+ imgs = x.reshape(B * N, C, H, W)
197
+ feats_chunks = []
198
+ with torch.no_grad():
199
+ for start in range(0, imgs.size(0), chunk_size):
200
+ end = min(start + chunk_size, imgs.size(0))
201
+ feats_chunk = self.get_tokens(imgs[start:end]) # (chunk, T, D)
202
+ feats_chunks.append(feats_chunk)
203
+ feats_token = torch.cat(feats_chunks, dim=0)
204
+ feats = feats_token.reshape(B, N, feats_token.shape[-2], -1) # (B, N, T, D)
205
+ feats = feats.float()
206
+ return self.core(feats) # logits: (B, num_classes) (B)
model/LTF_v2.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LTF V2: multi-branch (baseline + Level-1 MIL on patch tokens + Level-2 MIL on CLS + fusion).
3
+ V1 code in LTF.py is unchanged; this module is a separate copy for version V2.
4
+ """
5
+ from typing import Optional, Tuple
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+ from model.LTF import LTFCore
11
+ from mil_module.mil_core import MILCore
12
+
13
+
14
+ def _clip_vit_resblock_modules(transformer) -> list:
15
+ """OpenAI CLIP ViT: ``transformer.resblocks`` 为 Sequential 或 ModuleList。"""
16
+ rb = transformer.resblocks
17
+ if isinstance(rb, nn.Sequential):
18
+ return list(rb.children())
19
+ return list(rb)
20
+
21
+
22
+ def _count_clip_vit_depth(visual) -> int:
23
+ return len(_clip_vit_resblock_modules(visual.transformer))
24
+
25
+
26
+ class BagTokenShuffle(nn.Module):
27
+ """袋内 token 随机重排;训练时以概率 p_shuffle 触发(由 MILCore 传入)。"""
28
+
29
+ def forward(self, x, p_shuffle: float = 0.0, p_drop: float = 0.0, key_padding_mask=None, **kwargs):
30
+ del p_drop, key_padding_mask, kwargs
31
+ if (not self.training) or p_shuffle <= 0:
32
+ return x
33
+ if float(torch.rand((), device=x.device)) >= float(p_shuffle):
34
+ return x
35
+ B, N, D = x.shape
36
+ if N <= 1:
37
+ return x
38
+ perm = torch.rand(B, N, device=x.device).argsort(dim=1)
39
+ return x.gather(1, perm.unsqueeze(-1).expand(B, N, D))
40
+
41
+
42
+ class LTFCoreV2(nn.Module):
43
+ """
44
+ Baseline: identical logic to LTFCore (shared weights via inner LTFCore modules).
45
+ Level 1: per-tile patch tokens (no CLS, no global tile) -> MILCore -> Nt bag embeddings
46
+ -> learnable query token + MultiheadAttention -> level1 embedding(要求 N>=2)。
47
+ Level 2: all CLS tokens (global + every tile) -> MILCore -> bag embedding.
48
+ Fusion: MLP on concat(level1, level2, baseline_concat_feat).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ embed_dim=1024,
54
+ num_heads=8,
55
+ num_classes=2,
56
+ num_layers=1,
57
+ dropout=0.1,
58
+ tile_heads=2,
59
+ tile_layers=2,
60
+ use_global_image=True,
61
+ use_patch_transformer=True,
62
+ linear_classifier=False,
63
+ mil_heads=8,
64
+ fusion_hidden=512,
65
+ # Level-1 CrossTokenShuffle (仅 MIL 支路;baseline 不经过 shuffle / replace)
66
+ l1_cross_shuffle_p: float = 0.9,
67
+ l1_replace_ratio_range: Tuple[float, float] = (0.5, 0.9),
68
+ l1_with_replacement_p: float = 0.5,
69
+ # Level-2 CrossTokenShuffle
70
+ l2_cross_shuffle_p: float = 0.5,
71
+ l2_replace_ratio_range: Tuple[float, float] = (0.1, 0.5),
72
+ l2_with_replacement_p: float = 0.5,
73
+ bag_token_shuffle_p: float = 0.5,
74
+ cross_shuffle_min_keep_original: int = 0,
75
+ ):
76
+ super().__init__()
77
+ self.embed_dim = embed_dim
78
+ self.use_global_image = use_global_image
79
+ self.num_classes = num_classes
80
+
81
+ self.l1_cross_shuffle_p = float(l1_cross_shuffle_p)
82
+ self.l1_replace_ratio_range = (float(l1_replace_ratio_range[0]), float(l1_replace_ratio_range[1]))
83
+ self.l1_with_replacement_p = float(l1_with_replacement_p)
84
+ self.l2_cross_shuffle_p = float(l2_cross_shuffle_p)
85
+ self.l2_replace_ratio_range = (float(l2_replace_ratio_range[0]), float(l2_replace_ratio_range[1]))
86
+ self.l2_with_replacement_p = float(l2_with_replacement_p)
87
+ self.bag_token_shuffle_p = float(bag_token_shuffle_p)
88
+ self.cross_shuffle_min_keep_original = int(cross_shuffle_min_keep_original)
89
+
90
+ self.baseline_core = LTFCore(
91
+ embed_dim=embed_dim,
92
+ num_heads=num_heads,
93
+ num_classes=num_classes,
94
+ num_layers=num_layers,
95
+ dropout=dropout,
96
+ tile_heads=tile_heads,
97
+ tile_layers=tile_layers,
98
+ use_global_image=use_global_image,
99
+ use_patch_transformer=use_patch_transformer,
100
+ linear_classifier=linear_classifier,
101
+ )
102
+
103
+ mil_kw = dict(
104
+ attn_hidden=min(256, embed_dim),
105
+ attn_dropout=dropout,
106
+ attn_bag_mode="embed_cls",
107
+ )
108
+ self.mil_level1 = MILCore(
109
+ d_in=embed_dim,
110
+ num_classes=num_classes,
111
+ mil_head_pooling="attn",
112
+ mil_head_kwargs=mil_kw,
113
+ use_cross_shuffle=True,
114
+ trans=BagTokenShuffle(),
115
+ allow_trans_token_drop=False,
116
+ )
117
+ self.mil_level2 = MILCore(
118
+ d_in=embed_dim,
119
+ num_classes=num_classes,
120
+ mil_head_pooling="attn",
121
+ mil_head_kwargs=mil_kw,
122
+ use_cross_shuffle=True,
123
+ trans=BagTokenShuffle(),
124
+ allow_trans_token_drop=False,
125
+ )
126
+
127
+ self.level1_query = nn.Parameter(torch.zeros(1, 1, embed_dim))
128
+ mh = mil_heads if embed_dim % mil_heads == 0 else 8
129
+ while embed_dim % mh != 0:
130
+ mh -= 1
131
+ if mh < 1:
132
+ mh = 1
133
+ self.level1_aggregate_attn = nn.MultiheadAttention(
134
+ embed_dim, mh, dropout=dropout, batch_first=True
135
+ )
136
+
137
+ self.head_level1 = nn.Linear(embed_dim, num_classes)
138
+ self.head_level2 = nn.Linear(embed_dim, num_classes)
139
+
140
+ classifier_in_dim = embed_dim * 2 if use_global_image else embed_dim
141
+ fusion_in = embed_dim + embed_dim + classifier_in_dim
142
+ self.fusion_head = nn.Sequential(
143
+ nn.Linear(fusion_in, fusion_hidden),
144
+ nn.ReLU(),
145
+ nn.Dropout(p=dropout),
146
+ nn.Linear(fusion_hidden, num_classes),
147
+ )
148
+
149
+ nn.init.trunc_normal_(self.level1_query, std=0.02)
150
+
151
+ @staticmethod
152
+ def _sample_replace_ratio(low: float, high: float, device: torch.device) -> float:
153
+ lo, hi = min(low, high), max(low, high)
154
+ lo = max(0.0, lo)
155
+ hi = min(1.0, hi)
156
+ if hi <= lo:
157
+ return float(lo)
158
+ u = torch.rand((), device=device)
159
+ return float(lo + (hi - lo) * u)
160
+
161
+ def _apply_patch_transformer(self, x):
162
+ """x: (B, N, T, D) -> same shape after optional token-level encoder."""
163
+ B_2, N_2, T_2, D_2 = x.shape
164
+ xf = x.reshape(B_2 * N_2, T_2, D_2)
165
+ if self.baseline_core.patch_transformer is not None:
166
+ xf = self.baseline_core.patch_transformer(xf)
167
+ return xf.reshape(B_2, N_2, T_2, D_2)
168
+
169
+ def _forward_baseline_from_xfeat(self, x_feat):
170
+ """Run LTF classifier heads on processed tokens (same as LTFCore tail)."""
171
+ B_2, N_2, T_2, D_2 = x_feat.shape
172
+ bc = self.baseline_core
173
+ new_cls = x_feat[:, :, 0, :]
174
+ new_x = x_feat[:, :, 1:, :]
175
+ x_flat = new_x.reshape(-1, D_2)
176
+ x_result = bc.patch_classifier(x_flat)
177
+ x_result = x_result.reshape(B_2, N_2, T_2 - 1, 1)
178
+
179
+ main_feat = new_cls[:, 0]
180
+ tile_feats = new_cls[:, 1:]
181
+ cls_token = bc.cls_token.expand(B_2, 1, -1)
182
+ feature_tile = torch.cat([cls_token, tile_feats], dim=1)
183
+ feature_encoded = bc.tile_transformer(feature_tile)
184
+ cls_out = feature_encoded[:, 0]
185
+
186
+ if bc.use_global_image:
187
+ concat_feat = torch.cat([main_feat, cls_out], dim=1)
188
+ else:
189
+ concat_feat = cls_out
190
+ logits_b = bc.classifier(concat_feat)
191
+ jpeg_b = torch.sigmoid(bc.jpeg_estimate(cls_out))
192
+ return logits_b, x_result, jpeg_b, concat_feat
193
+
194
+ def forward(
195
+ self,
196
+ x,
197
+ labels: Optional[torch.Tensor] = None,
198
+ x_mil: Optional[torch.Tensor] = None,
199
+ ):
200
+ """
201
+ x: (B, N, T, D),要求 N>=2(global + 至少一个 tile),否则 Level1 无 patch 袋。
202
+ x_mil: 可选,与 x 同形状;若提供则 Level1/Level2 用其 token(如 CLIP 中间层),baseline 仍用 x。
203
+ labels: (B,) 0/1,训练时用于 Level1/2 CrossTokenShuffle;baseline 路径不使用 replace/shuffle。
204
+ Returns dict with logits per branch + baseline aux outputs.
205
+ """
206
+ x_feat = self._apply_patch_transformer(x)
207
+ if x_mil is not None:
208
+ if x_mil.shape != x.shape:
209
+ raise ValueError(f"x_mil shape {tuple(x_mil.shape)} != x shape {tuple(x.shape)}")
210
+ x_mil_feat = self._apply_patch_transformer(x_mil)
211
+ else:
212
+ x_mil_feat = x_feat
213
+ logits_baseline, patch_logits, jpeg_pred, concat_feat = self._forward_baseline_from_xfeat(
214
+ x_feat
215
+ )
216
+
217
+ B, N, T, D = x_mil_feat.shape
218
+ if N < 2:
219
+ raise ValueError(f"LTFCoreV2 expects N>=2 (global + tiles), got N={N}")
220
+ Nt = N - 1
221
+ train_shuffle = self.training and labels is not None
222
+ p_bag_shuffle = self.bag_token_shuffle_p if train_shuffle else 0.0
223
+
224
+ patch_only = x_mil_feat[:, 1:, 1:, :].reshape(B * Nt, T - 1, D)
225
+ do1 = (
226
+ train_shuffle
227
+ and float(torch.rand((), device=x.device)) < self.l1_cross_shuffle_p
228
+ )
229
+ rr1 = self._sample_replace_ratio(*self.l1_replace_ratio_range, x.device)
230
+ wr1 = bool(float(torch.rand((), device=x.device)) < self.l1_with_replacement_p)
231
+ y1 = labels.unsqueeze(1).expand(-1, Nt).reshape(-1) if labels is not None else None
232
+ mk = self.cross_shuffle_min_keep_original
233
+ mil1 = self.mil_level1(
234
+ patch_only,
235
+ y=y1,
236
+ return_dict=True,
237
+ do_cross_shuffle=do1,
238
+ replace_ratio=rr1,
239
+ with_replacement=wr1,
240
+ detach_inserted=True,
241
+ cross_shuffle_min_keep=mk,
242
+ p_shuffle=p_bag_shuffle,
243
+ )
244
+ be = mil1["mil_head"]["bag_embed"]
245
+ if be is None:
246
+ raise RuntimeError("MIL level1 expected bag_embed (use attn + embed_cls).")
247
+ bag_seq = be.reshape(B, Nt, D)
248
+ q = self.level1_query.expand(B, -1, -1)
249
+ agg_out, _ = self.level1_aggregate_attn(q, bag_seq, bag_seq)
250
+ level1_emb = agg_out.squeeze(1)
251
+
252
+ cls_seq = x_mil_feat[:, :, 0, :]
253
+ do2 = (
254
+ train_shuffle
255
+ and float(torch.rand((), device=x.device)) < self.l2_cross_shuffle_p
256
+ )
257
+ rr2 = self._sample_replace_ratio(*self.l2_replace_ratio_range, x.device)
258
+ wr2 = bool(float(torch.rand((), device=x.device)) < self.l2_with_replacement_p)
259
+ mil2 = self.mil_level2(
260
+ cls_seq,
261
+ y=labels,
262
+ return_dict=True,
263
+ do_cross_shuffle=do2,
264
+ replace_ratio=rr2,
265
+ with_replacement=wr2,
266
+ detach_inserted=True,
267
+ cross_shuffle_min_keep=mk,
268
+ p_shuffle=p_bag_shuffle,
269
+ )
270
+ be2 = mil2["mil_head"]["bag_embed"]
271
+ if be2 is None:
272
+ raise RuntimeError("MIL level2 expected bag_embed (use attn + embed_cls).")
273
+ level2_emb = be2
274
+
275
+ logits_level1 = self.head_level1(level1_emb)
276
+ logits_level2 = self.head_level2(level2_emb)
277
+
278
+ fusion_in = torch.cat([level1_emb, level2_emb, concat_feat], dim=-1)
279
+ logits_fusion = self.fusion_head(fusion_in)
280
+
281
+ return {
282
+ "logits_baseline": logits_baseline,
283
+ "logits_level1": logits_level1,
284
+ "logits_level2": logits_level2,
285
+ "logits_fusion": logits_fusion,
286
+ "patch_logits": patch_logits,
287
+ "jpeg_pred": jpeg_pred,
288
+ }
289
+
290
+
291
+ class LTFNetV2(nn.Module):
292
+ """Frozen CLIP visual + LTFCoreV2. Input: (B, N, 3, H, W)."""
293
+
294
+ def __init__(
295
+ self,
296
+ clip_visual,
297
+ num_heads=8,
298
+ num_classes=2,
299
+ num_layers=1,
300
+ dropout=0.2,
301
+ tile_heads=2,
302
+ tile_layers=2,
303
+ use_global_image=True,
304
+ use_patch_transformer=True,
305
+ linear_classifier=False,
306
+ mil_heads=8,
307
+ fusion_hidden=512,
308
+ l1_cross_shuffle_p: float = 0.9,
309
+ l1_replace_ratio_range: Tuple[float, float] = (0.5, 0.9),
310
+ l1_with_replacement_p: float = 0.5,
311
+ l2_cross_shuffle_p: float = 0.5,
312
+ l2_replace_ratio_range: Tuple[float, float] = (0.1, 0.5),
313
+ l2_with_replacement_p: float = 0.5,
314
+ bag_token_shuffle_p: float = 0.5,
315
+ cross_shuffle_min_keep_original: int = 0,
316
+ use_clip_intermediate_for_mil: bool = False,
317
+ clip_mil_stop_after_block_1based: int = 12,
318
+ ):
319
+ super().__init__()
320
+ self.visual = clip_visual
321
+ self.dtype = self.visual.conv1.weight.dtype
322
+
323
+ for p in self.visual.parameters():
324
+ p.requires_grad = False
325
+ self.visual.eval()
326
+
327
+ self.input_resolution = self.visual.input_resolution
328
+ self.embed_dim = self.visual.conv1.weight.shape[0]
329
+
330
+ self.use_clip_intermediate_for_mil = bool(use_clip_intermediate_for_mil)
331
+ self.clip_mil_stop_after_block_1based = int(clip_mil_stop_after_block_1based)
332
+ if self.use_clip_intermediate_for_mil:
333
+ n_bl = _count_clip_vit_depth(self.visual)
334
+ stop = self.clip_mil_stop_after_block_1based
335
+ if stop < 1 or stop > n_bl:
336
+ raise ValueError(
337
+ f"use_clip_intermediate_for_mil requires 1 <= v2_clip_mil_stop_after_block_1based "
338
+ f"<= {n_bl} (ViT depth), got {stop}"
339
+ )
340
+
341
+ self.core = LTFCoreV2(
342
+ embed_dim=self.embed_dim,
343
+ num_heads=num_heads,
344
+ num_classes=num_classes,
345
+ num_layers=num_layers,
346
+ dropout=dropout,
347
+ tile_heads=tile_heads,
348
+ tile_layers=tile_layers,
349
+ use_global_image=use_global_image,
350
+ use_patch_transformer=use_patch_transformer,
351
+ linear_classifier=linear_classifier,
352
+ mil_heads=mil_heads,
353
+ fusion_hidden=fusion_hidden,
354
+ l1_cross_shuffle_p=l1_cross_shuffle_p,
355
+ l1_replace_ratio_range=l1_replace_ratio_range,
356
+ l1_with_replacement_p=l1_with_replacement_p,
357
+ l2_cross_shuffle_p=l2_cross_shuffle_p,
358
+ l2_replace_ratio_range=l2_replace_ratio_range,
359
+ l2_with_replacement_p=l2_with_replacement_p,
360
+ bag_token_shuffle_p=bag_token_shuffle_p,
361
+ cross_shuffle_min_keep_original=cross_shuffle_min_keep_original,
362
+ )
363
+
364
+ def _stem_to_lnd(self, x_bchw: torch.Tensor) -> torch.Tensor:
365
+ """(B,3,H,W) -> (L,N,D) CLIP transformer 输入。"""
366
+ x = x_bchw.type(self.dtype)
367
+ x = self.visual.conv1(x)
368
+ x = x.reshape(x.shape[0], x.shape[1], -1)
369
+ x = x.permute(0, 2, 1)
370
+ cls = self.visual.class_embedding.to(x.dtype) + torch.zeros(
371
+ x.shape[0], 1, x.shape[2], dtype=x.dtype, device=x.device
372
+ )
373
+ x = torch.cat([cls, x], dim=1)
374
+ x = x + self.visual.positional_embedding.to(x.dtype)
375
+ x = self.visual.ln_pre(x)
376
+ return x.permute(1, 0, 2)
377
+
378
+ def _visual_tokens_from_imgs(
379
+ self, imgs_bchw: torch.Tensor
380
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
381
+ """
382
+ 返回 (tokens_final_NLD, tokens_mil_NLD | None)。
383
+ 若未启用中间层 MIL,第二项为 None(与旧版单路前向一致)。
384
+ """
385
+ x = self._stem_to_lnd(imgs_bchw)
386
+ if not self.use_clip_intermediate_for_mil:
387
+ x = self.visual.transformer(x)
388
+ return x.permute(1, 0, 2), None
389
+ blocks = _clip_vit_resblock_modules(self.visual.transformer)
390
+ stop = self.clip_mil_stop_after_block_1based
391
+ for i in range(stop):
392
+ x = blocks[i](x)
393
+ x_mil = x.permute(1, 0, 2).clone()
394
+ for i in range(stop, len(blocks)):
395
+ x = blocks[i](x)
396
+ return x.permute(1, 0, 2), x_mil
397
+
398
+ def get_tokens(self, x) -> torch.Tensor:
399
+ """与旧版一致:完整 ViT 输出 (B, T, D),不拆中间层。"""
400
+ x = self._stem_to_lnd(x)
401
+ x = self.visual.transformer(x)
402
+ return x.permute(1, 0, 2)
403
+
404
+ def encode_image(self, image) -> torch.Tensor:
405
+ return self.visual(image.type(self.dtype))
406
+
407
+ def forward(self, x: torch.Tensor, labels: Optional[torch.Tensor] = None, chunk_size=256):
408
+ chunk_size = getattr(self, "eval_chunk_size", chunk_size)
409
+ B, N, C, H, W = x.shape
410
+ imgs = x.reshape(B * N, C, H, W)
411
+ feats_chunks = []
412
+ mil_chunks = []
413
+ with torch.no_grad():
414
+ for start in range(0, imgs.size(0), chunk_size):
415
+ end = min(start + chunk_size, imgs.size(0))
416
+ f_tok, m_tok = self._visual_tokens_from_imgs(imgs[start:end])
417
+ feats_chunks.append(f_tok)
418
+ if m_tok is not None:
419
+ mil_chunks.append(m_tok)
420
+ feats_token = torch.cat(feats_chunks, dim=0)
421
+ T = feats_token.shape[-2]
422
+ D = feats_token.shape[-1]
423
+ feats = feats_token.reshape(B, N, T, D).float()
424
+ if not mil_chunks:
425
+ return self.core(feats, labels=labels, x_mil=None)
426
+ mil_token = torch.cat(mil_chunks, dim=0).reshape(B, N, T, D).float()
427
+ return self.core(feats, labels=labels, x_mil=mil_token)
model/LTF_v3_3.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LTF V3.3: V3 + pool-logit gated fusion.
3
+
4
+ Motivation:
5
+ - Keep V3 outputs (logits_level1 / logits_level2 / logits_fusion) unchanged for training tools.
6
+ - Use "logit_pool" signals (attention-weighted instance logits) as a reliability cue to gate fusion
7
+ between level1_emb and level2_emb, while keeping fusion_head input dim = 2*embed_dim.
8
+ """
9
+
10
+ from typing import Optional, Tuple
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+ from model.LTF import LTFCore
16
+ from model.LTF_v2 import (
17
+ BagTokenShuffle,
18
+ _clip_vit_resblock_modules,
19
+ _count_clip_vit_depth,
20
+ )
21
+ from mil_module.mil_core import MILCore
22
+
23
+
24
+ class LTFCoreV3_3(nn.Module):
25
+ """
26
+ V3 core + a tiny gate that uses logit_pool(level1_tiles) and logit_pool(level2_cls-bag).
27
+
28
+ - Level1: patch tokens (tiles only) -> MIL per tile -> tile bag embeddings -> query attention -> level1_emb
29
+ Additionally: compute per-tile instance logits and attention weights -> logit_pool across tiles.
30
+ - Level2: CLS tokens (global + tiles) -> MIL (attn pooling) -> bag_embed = level2_emb
31
+ Additionally: compute logit_pool from MILHead's attn * inst_logits.
32
+ - Fusion: g = sigmoid(MLP([l1_pool_logits, l2_pool_logits])) in (0,1),
33
+ fusion_in = [g*level1_emb, (1-g)*level2_emb] -> fusion_head (same as V3 input dim).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ embed_dim=1024,
39
+ num_heads=8,
40
+ num_classes=2,
41
+ num_layers=1,
42
+ dropout=0.1,
43
+ tile_heads=2,
44
+ tile_layers=2,
45
+ use_global_image=True,
46
+ use_patch_transformer=True,
47
+ linear_classifier=False,
48
+ mil_heads=8,
49
+ fusion_hidden=512,
50
+ l1_cross_shuffle_p: float = 0.9,
51
+ l1_replace_ratio_range: Tuple[float, float] = (0.5, 0.9),
52
+ l1_with_replacement_p: float = 0.5,
53
+ l2_cross_shuffle_p: float = 0.5,
54
+ l2_replace_ratio_range: Tuple[float, float] = (0.1, 0.5),
55
+ l2_with_replacement_p: float = 0.5,
56
+ bag_token_shuffle_p: float = 0.5,
57
+ cross_shuffle_min_keep_original: int = 0,
58
+ use_mil_cross_shuffle: bool = True,
59
+ pool_gate_hidden: int = 16,
60
+ use_pool_gate_fusion: bool = True,
61
+ ):
62
+ super().__init__()
63
+ self.embed_dim = embed_dim
64
+ self.use_global_image = use_global_image
65
+ self.num_classes = num_classes
66
+ self.use_mil_cross_shuffle = bool(use_mil_cross_shuffle)
67
+
68
+ self.l1_cross_shuffle_p = float(l1_cross_shuffle_p)
69
+ self.l1_replace_ratio_range = (
70
+ float(l1_replace_ratio_range[0]),
71
+ float(l1_replace_ratio_range[1]),
72
+ )
73
+ self.l1_with_replacement_p = float(l1_with_replacement_p)
74
+ self.l2_cross_shuffle_p = float(l2_cross_shuffle_p)
75
+ self.l2_replace_ratio_range = (
76
+ float(l2_replace_ratio_range[0]),
77
+ float(l2_replace_ratio_range[1]),
78
+ )
79
+ self.l2_with_replacement_p = float(l2_with_replacement_p)
80
+ self.bag_token_shuffle_p = float(bag_token_shuffle_p)
81
+ self.cross_shuffle_min_keep_original = int(cross_shuffle_min_keep_original)
82
+
83
+ self.use_pool_gate_fusion = bool(use_pool_gate_fusion)
84
+
85
+ # Keep the same core structure and frozen baseline classifier as V3 for compatibility.
86
+ self.baseline_core = LTFCore(
87
+ embed_dim=embed_dim,
88
+ num_heads=num_heads,
89
+ num_classes=num_classes,
90
+ num_layers=num_layers,
91
+ dropout=dropout,
92
+ tile_heads=tile_heads,
93
+ tile_layers=tile_layers,
94
+ use_global_image=use_global_image,
95
+ use_patch_transformer=use_patch_transformer,
96
+ linear_classifier=linear_classifier,
97
+ )
98
+ for p in self.baseline_core.classifier.parameters():
99
+ p.requires_grad = False
100
+
101
+ mil_kw = dict(
102
+ attn_hidden=min(256, embed_dim),
103
+ attn_dropout=dropout,
104
+ attn_bag_mode="embed_cls",
105
+ )
106
+ self.mil_level1 = MILCore(
107
+ d_in=embed_dim,
108
+ num_classes=num_classes,
109
+ mil_head_pooling="attn",
110
+ mil_head_kwargs=mil_kw,
111
+ use_cross_shuffle=self.use_mil_cross_shuffle,
112
+ trans=BagTokenShuffle(),
113
+ allow_trans_token_drop=False,
114
+ )
115
+ self.mil_level2 = MILCore(
116
+ d_in=embed_dim,
117
+ num_classes=num_classes,
118
+ mil_head_pooling="attn",
119
+ mil_head_kwargs=mil_kw,
120
+ use_cross_shuffle=self.use_mil_cross_shuffle,
121
+ trans=BagTokenShuffle(),
122
+ allow_trans_token_drop=False,
123
+ )
124
+
125
+ # Level-1 aggregation across tiles (query attention) + tile-level instance logits for logit_pool.
126
+ self.level1_query = nn.Parameter(torch.zeros(1, 1, embed_dim))
127
+ mh = mil_heads if embed_dim % mil_heads == 0 else 8
128
+ while embed_dim % mh != 0:
129
+ mh -= 1
130
+ if mh < 1:
131
+ mh = 1
132
+ self.level1_aggregate_attn = nn.MultiheadAttention(
133
+ embed_dim, mh, dropout=dropout, batch_first=True
134
+ )
135
+
136
+ # Heads
137
+ self.head_level1 = nn.Linear(embed_dim, num_classes)
138
+ self.head_level2 = nn.Linear(embed_dim, num_classes)
139
+ self.tile_inst_head = nn.Linear(embed_dim, num_classes)
140
+
141
+ fusion_in = embed_dim + embed_dim
142
+ self.fusion_head = nn.Sequential(
143
+ nn.Linear(fusion_in, fusion_hidden),
144
+ nn.ReLU(),
145
+ nn.Dropout(p=dropout),
146
+ nn.Linear(fusion_hidden, num_classes),
147
+ )
148
+
149
+ # Gate uses pooled logits from L1/L2: concat => [B, 2*C]
150
+ gate_in = 2 * num_classes
151
+ self.pool_gate = nn.Sequential(
152
+ nn.Linear(gate_in, int(pool_gate_hidden)),
153
+ nn.ReLU(),
154
+ nn.Linear(int(pool_gate_hidden), 1),
155
+ )
156
+
157
+ nn.init.trunc_normal_(self.level1_query, std=0.02)
158
+
159
+ @staticmethod
160
+ def _sample_replace_ratio(low: float, high: float, device: torch.device) -> float:
161
+ lo, hi = min(low, high), max(low, high)
162
+ lo = max(0.0, lo)
163
+ hi = min(1.0, hi)
164
+ if hi <= lo:
165
+ return float(lo)
166
+ u = torch.rand((), device=device)
167
+ return float(lo + (hi - lo) * u)
168
+
169
+ def _apply_patch_transformer(self, x):
170
+ b2, n2, t2, d2 = x.shape
171
+ xf = x.reshape(b2 * n2, t2, d2)
172
+ if self.baseline_core.patch_transformer is not None:
173
+ xf = self.baseline_core.patch_transformer(xf)
174
+ return xf.reshape(b2, n2, t2, d2)
175
+
176
+ @staticmethod
177
+ def _logit_pool_from_mil_head(mil_head_out: dict) -> torch.Tensor:
178
+ """
179
+ Compute logit_pool = sum(attn * inst_logits) over instances.
180
+ Requires pooling='attn' so that 'attn' is not None.
181
+ """
182
+ inst = mil_head_out.get("inst_logits")
183
+ attn = mil_head_out.get("attn")
184
+ if inst is None or attn is None:
185
+ raise RuntimeError("logit_pool requires MILHead outputs: inst_logits and attn.")
186
+ return (attn * inst).sum(dim=1)
187
+
188
+ def forward(
189
+ self,
190
+ x,
191
+ labels: Optional[torch.Tensor] = None,
192
+ x_mil: Optional[torch.Tensor] = None,
193
+ return_viz: bool = False,
194
+ ):
195
+ x_feat = self._apply_patch_transformer(x)
196
+ if x_mil is not None:
197
+ if x_mil.shape != x.shape:
198
+ raise ValueError(f"x_mil shape {tuple(x_mil.shape)} != x shape {tuple(x.shape)}")
199
+ x_mil_feat = self._apply_patch_transformer(x_mil)
200
+ else:
201
+ x_mil_feat = x_feat
202
+
203
+ b, n, t, d = x_mil_feat.shape
204
+ if n < 2:
205
+ raise ValueError(f"LTFCoreV3_3 expects N>=2 (global + tiles), got N={n}")
206
+ nt = n - 1
207
+ train_shuffle = self.training and labels is not None
208
+ p_bag_shuffle = self.bag_token_shuffle_p if train_shuffle else 0.0
209
+
210
+ # --- Level 1: patch tokens (tiles only, no CLS) ---
211
+ patch_only = x_mil_feat[:, 1:, 1:, :].reshape(b * nt, t - 1, d)
212
+ do1 = train_shuffle and float(torch.rand((), device=x.device)) < self.l1_cross_shuffle_p
213
+ rr1 = self._sample_replace_ratio(*self.l1_replace_ratio_range, x.device)
214
+ wr1 = bool(float(torch.rand((), device=x.device)) < self.l1_with_replacement_p)
215
+ y1 = labels.unsqueeze(1).expand(-1, nt).reshape(-1) if labels is not None else None
216
+ mk = self.cross_shuffle_min_keep_original
217
+ mil1 = self.mil_level1(
218
+ patch_only,
219
+ y=y1,
220
+ return_dict=True,
221
+ do_cross_shuffle=do1,
222
+ replace_ratio=rr1,
223
+ with_replacement=wr1,
224
+ detach_inserted=True,
225
+ cross_shuffle_min_keep=mk,
226
+ p_shuffle=p_bag_shuffle,
227
+ )
228
+ be = mil1["mil_head"]["bag_embed"]
229
+ if be is None:
230
+ raise RuntimeError("MIL level1 expected bag_embed (use attn + embed_cls).")
231
+ bag_seq = be.reshape(b, nt, d) # per-tile embeddings
232
+
233
+ # Query-attn aggregate -> level1_emb
234
+ q = self.level1_query.expand(b, -1, -1)
235
+ agg_out, attn_w = self.level1_aggregate_attn(
236
+ q, bag_seq, bag_seq, need_weights=True, average_attn_weights=False
237
+ )
238
+ level1_emb = agg_out.squeeze(1) # [B, D]
239
+
240
+ # logit_pool across tiles using the same attention weights.
241
+ # attn_w: [B, heads, 1, Nt] when average_attn_weights=False
242
+ if attn_w is None:
243
+ raise RuntimeError("Expected attention weights from level1_aggregate_attn.")
244
+ tile_attn = attn_w.mean(dim=1).squeeze(1) # [B, Nt]
245
+ tile_inst_logits = self.tile_inst_head(bag_seq) # [B, Nt, C]
246
+ level1_logit_pool = (tile_attn.unsqueeze(-1) * tile_inst_logits).sum(dim=1) # [B, C]
247
+
248
+ # --- Level 2: CLS tokens (global + tiles) ---
249
+ cls_seq = x_mil_feat[:, :, 0, :] # [B, N, D]
250
+ do2 = train_shuffle and float(torch.rand((), device=x.device)) < self.l2_cross_shuffle_p
251
+ rr2 = self._sample_replace_ratio(*self.l2_replace_ratio_range, x.device)
252
+ wr2 = bool(float(torch.rand((), device=x.device)) < self.l2_with_replacement_p)
253
+ mil2 = self.mil_level2(
254
+ cls_seq,
255
+ y=labels,
256
+ return_dict=True,
257
+ do_cross_shuffle=do2,
258
+ replace_ratio=rr2,
259
+ with_replacement=wr2,
260
+ detach_inserted=True,
261
+ cross_shuffle_min_keep=mk,
262
+ p_shuffle=p_bag_shuffle,
263
+ )
264
+ mil2_head = mil2["mil_head"]
265
+ be2 = mil2_head["bag_embed"]
266
+ if be2 is None:
267
+ raise RuntimeError("MIL level2 expected bag_embed (use attn + embed_cls).")
268
+ level2_emb = be2 # [B, D]
269
+ level2_logit_pool = self._logit_pool_from_mil_head(mil2_head) # [B, C]
270
+
271
+ logits_level1 = self.head_level1(level1_emb)
272
+ logits_level2 = self.head_level2(level2_emb)
273
+
274
+ # --- Fusion: pool-logit gate -> keep fusion input dim unchanged ---
275
+ if self.use_pool_gate_fusion:
276
+ gate_in = torch.cat([level1_logit_pool, level2_logit_pool], dim=-1) # [B, 2C]
277
+ g = torch.sigmoid(self.pool_gate(gate_in)) # [B, 1]
278
+ fusion_in = torch.cat([g * level1_emb, (1.0 - g) * level2_emb], dim=-1) # [B, 2D]
279
+ else:
280
+ fusion_in = torch.cat([level1_emb, level2_emb], dim=-1)
281
+ logits_fusion = self.fusion_head(fusion_in)
282
+
283
+ out = {
284
+ "logits_level1": logits_level1,
285
+ "logits_level2": logits_level2,
286
+ "logits_fusion": logits_fusion,
287
+ }
288
+ if return_viz:
289
+ mil1_head = mil1.get("mil_head", {})
290
+ out["viz"] = {
291
+ # Branch-level raw outputs
292
+ "logits_level1": logits_level1,
293
+ "logits_level2": logits_level2,
294
+ "logits_fusion": logits_fusion,
295
+ # MIL pooled logits and gate inputs
296
+ "level1_logit_pool": level1_logit_pool,
297
+ "level2_logit_pool": level2_logit_pool,
298
+ # Embeddings before branch heads / fusion
299
+ "level1_emb": level1_emb,
300
+ "level2_emb": level2_emb,
301
+ "fusion_in": fusion_in,
302
+ # Attention maps and instance logits
303
+ "tile_attn": tile_attn,
304
+ "tile_inst_logits": tile_inst_logits,
305
+ "level1_patch_attn": mil1_head.get("attn"),
306
+ "level1_patch_inst_logits": mil1_head.get("inst_logits"),
307
+ "level2_attn": mil2_head.get("attn"),
308
+ "level2_inst_logits": mil2_head.get("inst_logits"),
309
+ "num_tiles": torch.full(
310
+ (b, 1), nt, device=logits_fusion.device, dtype=torch.int32
311
+ ),
312
+ "num_patches_per_tile": torch.full(
313
+ (b, 1), (t - 1), device=logits_fusion.device, dtype=torch.int32
314
+ ),
315
+ # Scalar gate
316
+ "gate": g if self.use_pool_gate_fusion else torch.full(
317
+ (b, 1), 0.5, device=logits_fusion.device, dtype=logits_fusion.dtype
318
+ ),
319
+ "use_pool_gate_fusion": torch.tensor(
320
+ [1 if self.use_pool_gate_fusion else 0],
321
+ device=logits_fusion.device,
322
+ dtype=logits_fusion.dtype,
323
+ ),
324
+ }
325
+ return out
326
+
327
+
328
+ class LTFNetV3_3(nn.Module):
329
+ """Frozen CLIP visual + LTFCoreV3_3. Input: (B, N, 3, H, W)."""
330
+
331
+ def __init__(
332
+ self,
333
+ clip_visual,
334
+ num_heads=8,
335
+ num_classes=2,
336
+ num_layers=1,
337
+ dropout=0.2,
338
+ tile_heads=2,
339
+ tile_layers=2,
340
+ use_global_image=True,
341
+ use_patch_transformer=True,
342
+ linear_classifier=False,
343
+ mil_heads=8,
344
+ fusion_hidden=512,
345
+ l1_cross_shuffle_p: float = 0.9,
346
+ l1_replace_ratio_range: Tuple[float, float] = (0.5, 0.9),
347
+ l1_with_replacement_p: float = 0.5,
348
+ l2_cross_shuffle_p: float = 0.5,
349
+ l2_replace_ratio_range: Tuple[float, float] = (0.1, 0.5),
350
+ l2_with_replacement_p: float = 0.5,
351
+ bag_token_shuffle_p: float = 0.5,
352
+ cross_shuffle_min_keep_original: int = 0,
353
+ use_mil_cross_shuffle: bool = True,
354
+ use_clip_intermediate_for_mil: bool = False,
355
+ clip_mil_stop_after_block_1based: int = 12,
356
+ pool_gate_hidden: int = 16,
357
+ use_pool_gate_fusion: bool = True,
358
+ ):
359
+ super().__init__()
360
+ self.visual = clip_visual
361
+ self.dtype = self.visual.conv1.weight.dtype
362
+
363
+ for p in self.visual.parameters():
364
+ p.requires_grad = False
365
+ self.visual.eval()
366
+
367
+ self.input_resolution = self.visual.input_resolution
368
+ self.embed_dim = self.visual.conv1.weight.shape[0]
369
+
370
+ self.use_clip_intermediate_for_mil = bool(use_clip_intermediate_for_mil)
371
+ self.clip_mil_stop_after_block_1based = int(clip_mil_stop_after_block_1based)
372
+ if self.use_clip_intermediate_for_mil:
373
+ n_bl = _count_clip_vit_depth(self.visual)
374
+ stop = self.clip_mil_stop_after_block_1based
375
+ if stop < 1 or stop > n_bl:
376
+ raise ValueError(
377
+ f"use_clip_intermediate_for_mil requires 1 <= clip_mil_stop_after_block_1based "
378
+ f"<= {n_bl} (ViT depth), got {stop}"
379
+ )
380
+
381
+ self.core = LTFCoreV3_3(
382
+ embed_dim=self.embed_dim,
383
+ num_heads=num_heads,
384
+ num_classes=num_classes,
385
+ num_layers=num_layers,
386
+ dropout=dropout,
387
+ tile_heads=tile_heads,
388
+ tile_layers=tile_layers,
389
+ use_global_image=use_global_image,
390
+ use_patch_transformer=use_patch_transformer,
391
+ linear_classifier=linear_classifier,
392
+ mil_heads=mil_heads,
393
+ fusion_hidden=fusion_hidden,
394
+ l1_cross_shuffle_p=l1_cross_shuffle_p,
395
+ l1_replace_ratio_range=l1_replace_ratio_range,
396
+ l1_with_replacement_p=l1_with_replacement_p,
397
+ l2_cross_shuffle_p=l2_cross_shuffle_p,
398
+ l2_replace_ratio_range=l2_replace_ratio_range,
399
+ l2_with_replacement_p=l2_with_replacement_p,
400
+ bag_token_shuffle_p=bag_token_shuffle_p,
401
+ cross_shuffle_min_keep_original=cross_shuffle_min_keep_original,
402
+ use_mil_cross_shuffle=use_mil_cross_shuffle,
403
+ pool_gate_hidden=pool_gate_hidden,
404
+ use_pool_gate_fusion=use_pool_gate_fusion,
405
+ )
406
+
407
+ def _stem_to_lnd(self, x_bchw: torch.Tensor) -> torch.Tensor:
408
+ x = x_bchw.type(self.dtype)
409
+ x = self.visual.conv1(x)
410
+ x = x.reshape(x.shape[0], x.shape[1], -1)
411
+ x = x.permute(0, 2, 1)
412
+ cls = self.visual.class_embedding.to(x.dtype) + torch.zeros(
413
+ x.shape[0], 1, x.shape[2], dtype=x.dtype, device=x.device
414
+ )
415
+ x = torch.cat([cls, x], dim=1)
416
+ x = x + self.visual.positional_embedding.to(x.dtype)
417
+ x = self.visual.ln_pre(x)
418
+ return x.permute(1, 0, 2)
419
+
420
+ def _visual_tokens_from_imgs(self, imgs_bchw: torch.Tensor):
421
+ x = self._stem_to_lnd(imgs_bchw)
422
+ if not self.use_clip_intermediate_for_mil:
423
+ x = self.visual.transformer(x)
424
+ return x.permute(1, 0, 2), None
425
+ blocks = _clip_vit_resblock_modules(self.visual.transformer)
426
+ stop = self.clip_mil_stop_after_block_1based
427
+ for i in range(stop):
428
+ x = blocks[i](x)
429
+ x_mil = x.permute(1, 0, 2).clone()
430
+ for i in range(stop, len(blocks)):
431
+ x = blocks[i](x)
432
+ return x.permute(1, 0, 2), x_mil
433
+
434
+ def forward(
435
+ self,
436
+ x: torch.Tensor,
437
+ labels: Optional[torch.Tensor] = None,
438
+ chunk_size=256,
439
+ return_viz: bool = False,
440
+ ):
441
+ chunk_size = getattr(self, "eval_chunk_size", chunk_size)
442
+ b, n, c, h, w = x.shape
443
+ imgs = x.reshape(b * n, c, h, w)
444
+ feats_chunks = []
445
+ mil_chunks = []
446
+ with torch.no_grad():
447
+ for start in range(0, imgs.size(0), chunk_size):
448
+ end = min(start + chunk_size, imgs.size(0))
449
+ f_tok, m_tok = self._visual_tokens_from_imgs(imgs[start:end])
450
+ feats_chunks.append(f_tok)
451
+ if m_tok is not None:
452
+ mil_chunks.append(m_tok)
453
+ feats_token = torch.cat(feats_chunks, dim=0)
454
+ t = feats_token.shape[-2]
455
+ d = feats_token.shape[-1]
456
+ feats = feats_token.reshape(b, n, t, d).float()
457
+ if not mil_chunks:
458
+ return self.core(feats, labels=labels, x_mil=None, return_viz=return_viz)
459
+ mil_token = torch.cat(mil_chunks, dim=0).reshape(b, n, t, d).float()
460
+ return self.core(feats, labels=labels, x_mil=mil_token, return_viz=return_viz)
461
+
model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """LTF v3.3 historical model package."""
release_manifest.json ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "LTF v3.3",
3
+ "checkpoint": "epoch_20.pth",
4
+ "historical_run": "20260326_012209_ViT-L-14_genimage_v3_v3_3",
5
+ "training_run": "20260325_092132_ViT-L-14_genimage_sd14_pure_test_v3_3_float",
6
+ "historical_fusion_macro8_accuracy": 0.9839,
7
+ "historical_images": 100000,
8
+ "threshold": 0.5,
9
+ "source_note": "Historical release core reconstructed from git 65c18ee5868ced87a27d048261e237632ad5d704; original training-day source snapshot unavailable.",
10
+ "adaptations": [
11
+ "Tensor-only checkpoint loading",
12
+ "Portable evaluation configuration"
13
+ ],
14
+ "files": {
15
+ "ltf_final/__init__.py": {
16
+ "sha256": "027e133874edcab1a9fa1f7786618e91e5bad141d87d5f592cdc526c379b6fbb",
17
+ "bytes": 175
18
+ },
19
+ "ltf_final/detector.py": {
20
+ "sha256": "22b6b8ce000d0f02896f1d10df65fbfaa2107bf2ed83d218df306241279f0e91",
21
+ "bytes": 6073
22
+ },
23
+ "model/LTF.py": {
24
+ "sha256": "4e76ed3dcf03cf56ca5c843220e15cddc84d1db2b95c823d8fe8bdab0cc0e279",
25
+ "bytes": 8147
26
+ },
27
+ "model/LTF_v2.py": {
28
+ "sha256": "aea134d55d942c606b3876a7c98eb39092a709c1d2a863e3e3074523ab388de9",
29
+ "bytes": 16763
30
+ },
31
+ "model/LTF_v3_3.py": {
32
+ "sha256": "f771d49441a2702f241fd9debc10ee49da20414db8759d2b8a551b18674a4202",
33
+ "bytes": 18523
34
+ },
35
+ "model/__init__.py": {
36
+ "sha256": "e49d18603c3f0259d1c37da6ba7fabeb986f8e44e2b853d7b86cca1c5f94a433",
37
+ "bytes": 41
38
+ },
39
+ "mil_module/__init__.py": {
40
+ "sha256": "a64b516cada39fe04a117297a47f6bec29d34e7e6e7462ee80d4f2a1e180628b",
41
+ "bytes": 1025
42
+ },
43
+ "mil_module/cross_shuffle.py": {
44
+ "sha256": "4a995e4408708b1bc784bfa252e02e00ed5c91b27629543cf5eaca59352279c7",
45
+ "bytes": 6697
46
+ },
47
+ "mil_module/mil_core.py": {
48
+ "sha256": "87c85d888540858d45f869ba1e24efcfb553ea525d35518790b54e33277e784e",
49
+ "bytes": 8350
50
+ },
51
+ "mil_module/mil_head.py": {
52
+ "sha256": "bf383e4bf91fec6fc6788af71cc2b6e81191cf5e2137b0b443f6a2d9dada1e4f",
53
+ "bytes": 8167
54
+ },
55
+ "mil_module/mil_head_beta.py": {
56
+ "sha256": "46e3da092b20de6f2bc8400590d2aa3a3156584d1c79254b7a11abb2fd5ceced",
57
+ "bytes": 7189
58
+ },
59
+ "utils/__init__.py": {
60
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
61
+ "bytes": 0
62
+ },
63
+ "utils/tile_dataset_srm.py": {
64
+ "sha256": "49106f01b4fda4d10e7db8364967988a68a00c39daa2dde8fe89f52506531642",
65
+ "bytes": 14623
66
+ },
67
+ "configs/eval.yaml": {
68
+ "sha256": "4ce443731e70a4ac4eb71fb56103f33373e179fefe766c3e6a02635efdb94efc",
69
+ "bytes": 1039
70
+ },
71
+ "weights/epoch_20.pth": {
72
+ "sha256": "32e9fa2e38e04d6d0221c844d650f0d45f7235bbf898b5ad665543d8be580031",
73
+ "bytes": 62249626
74
+ },
75
+ "backbones/ViT-L-14.pt": {
76
+ "sha256": "b8cca3fd41ae0c99ba7e8951adf17d267cdb84cd88be6f7c2e0eca1737a03836",
77
+ "bytes": 932768134
78
+ }
79
+ }
80
+ }
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.1.2
2
+ torchvision==0.16.2
3
+ numpy>=1.24,<2
4
+ Pillow>=10.2
5
+ PyYAML>=6.0
6
+ scikit-learn>=1.2
7
+ tqdm>=4.66
8
+ tensorboard>=2.15
9
+ openai-clip==1.0.1
results/genimage8_logged_metrics.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ subset,n_real,n_fake,acc_level1,acc_real_level1,acc_fake_level1,auc_level1,ap_level1,best_thr_level1,best_acc_level1,acc_level2,acc_real_level2,acc_fake_level2,auc_level2,ap_level2,best_thr_level2,best_acc_level2,acc_fusion,acc_real_fusion,acc_fake_fusion,auc_fusion,ap_fusion,best_thr_fusion,best_acc_fusion
2
+ imagenet_midjourney,6000,6000,0.9194,0.9288,0.91,0.9755,0.9755,0.46542627,0.921,0.9692,0.956,0.9825,0.9953,0.9938,0.64407796,0.9722,0.9734,0.9812,0.9657,0.9962,0.995,0.37580317,0.9758
3
+ imagenet_ai_0419_sdv4,6000,6000,0.962,0.924,1.0,0.9999,0.9999,0.99286216,0.9978,0.9763,0.9527,1.0,1.0,1.0,0.974082,0.9979,0.989,0.978,1.0,1.0,1.0,0.99918264,0.9999
4
+ imagenet_ai_0424_sdv5,8000,8000,0.96,0.92,1.0,0.9998,0.9998,0.9958075,0.9961,0.9748,0.9507,0.9989,0.9998,0.9998,0.9699074,0.9963,0.9862,0.9735,0.9989,0.9999,0.9999,0.9994531,0.9988
5
+ imagenet_ai_0508_adm,6000,6000,0.9501,0.9177,0.9825,0.9929,0.9928,0.7772057,0.9609,0.9727,0.9543,0.991,0.9976,0.9975,0.7358004,0.9783,0.9841,0.976,0.9922,0.9988,0.9986,0.57335776,0.9858
6
+ imagenet_glide,6000,6000,0.956,0.9275,0.9845,0.9941,0.9944,0.79664856,0.9657,0.9754,0.9525,0.9983,0.9992,0.9992,0.8827087,0.9882,0.9816,0.9768,0.9863,0.9981,0.9978,0.70165557,0.9822
7
+ imagenet_ai_0424_wukong,6000,6000,0.9627,0.9257,0.9998,0.9998,0.9998,0.9886967,0.9959,0.9741,0.9483,0.9998,0.9999,0.9999,0.952306,0.9965,0.989,0.978,1.0,1.0,1.0,0.98438674,0.9988
8
+ imagenet_ai_0419_vqdm,6000,6000,0.9597,0.9223,0.9972,0.9983,0.9983,0.92264795,0.9829,0.9736,0.9538,0.9933,0.9985,0.9986,0.812101,0.9818,0.9868,0.9755,0.998,0.9997,0.9997,0.8302674,0.9928
9
+ imagenet_ai_0419_biggan,6000,6000,0.9512,0.9265,0.9758,0.9888,0.9869,0.5910971,0.9535,0.9752,0.9507,0.9998,0.9998,0.9998,0.9196974,0.9932,0.9809,0.9753,0.9865,0.9981,0.998,0.54691106,0.9816
results/summary.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source_log": "/raid5/chr/AIGCD/realguard_new/evaluate_logs/20260326_012209_ViT-L-14_genimage_v3_v3_3/test.log",
3
+ "source": "historical logged metrics (rounded to 4 decimals), not newly re-evaluated",
4
+ "images": 100000,
5
+ "subsets": 8,
6
+ "threshold": 0.5,
7
+ "average": "unweighted mean over the eight subsets",
8
+ "acc_level1": 0.9526,
9
+ "acc_level2": 0.9739,
10
+ "acc_fusion": 0.9839,
11
+ "weights": "weights/epoch_20.pth",
12
+ "weights_sha256": "32e9fa2e38e04d6d0221c844d650f0d45f7235bbf898b5ad665543d8be580031"
13
+ }
utils/__init__.py ADDED
File without changes
utils/tile_dataset_srm.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageOps, ImageFilter
2
+ import random
3
+ import io, os
4
+ import math
5
+ import torch
6
+ from torch.utils.data import Dataset, DataLoader
7
+ from torchvision import transforms
8
+ from torchvision.transforms import InterpolationMode
9
+ BICUBIC = InterpolationMode.BICUBIC
10
+
11
+ def resize_with_random_scale(image: Image.Image, p: float = 0.5, scale_range=(0.25, 2.0)) -> Image.Image:
12
+ if random.random() > p:
13
+ return image
14
+
15
+ w, h = image.size
16
+ scale = random.uniform(*scale_range)
17
+ new_w = max(1, int(w * scale))
18
+ new_h = max(1, int(h * scale))
19
+ return image.resize((new_w, new_h), resample=Image.BICUBIC)
20
+
21
+ def pad_crop_image(image, target_width, target_height):
22
+ src_width, src_height = image.size
23
+ # 0
24
+ pad_left = pad_right = pad_top = pad_bottom = 0
25
+ crop_left = crop_top = 0
26
+
27
+ if src_width < target_width:
28
+ pad_left = random.randint(0, target_width - src_width)
29
+ pad_right = target_width - src_width - pad_left
30
+ else:
31
+ crop_left = random.randint(0, src_width - target_width)
32
+
33
+ if src_height < target_height:
34
+ pad_top = random.randint(0, target_height - src_height)
35
+ pad_bottom = target_height - src_height - pad_top
36
+ else:
37
+ crop_top = random.randint(0, src_height - target_height)
38
+
39
+ if src_width > target_width or src_height > target_height:
40
+ left = crop_left if src_width > target_width else 0
41
+ top = crop_top if src_height > target_height else 0
42
+ right = left + target_width
43
+ bottom = top + target_height
44
+ image = image.crop((left, top, right, bottom))
45
+ # Padding
46
+ if src_width < target_width or src_height < target_height:
47
+ image = ImageOps.expand(
48
+ image,
49
+ border=(pad_left, pad_top, pad_right, pad_bottom),
50
+ fill=(0, 0, 0)
51
+ )
52
+ return image
53
+
54
+ def get_start_positions(image_length, block_size, bias=0):
55
+ if image_length <= block_size:
56
+ return [0+bias]
57
+ num_blocks = image_length // block_size + (image_length % block_size > 0)
58
+ if num_blocks == 1:
59
+ return [0+bias]
60
+ total_stride = (image_length - block_size) / (num_blocks - 1)
61
+ positions = [int(round(i * total_stride) + bias) for i in range(num_blocks - 1)]
62
+ positions.append(image_length - block_size + bias)
63
+ return positions
64
+
65
+ def crop_image_to_tiles(image, block_size):
66
+ """
67
+ crop to block_size x block_size
68
+ """
69
+ width, height = image.size
70
+ x_positions = get_start_positions(width, block_size)
71
+ y_positions = get_start_positions(height, block_size)
72
+ tiles = []
73
+ for y in y_positions:
74
+ for x in x_positions:
75
+ tiles.append(image.crop((x, y, x + block_size, y + block_size)))
76
+ return tiles
77
+
78
+ def crop_image_to_tiles_new(image, block_size):
79
+ width, height = image.size
80
+ x_positions = get_start_positions(width, block_size)
81
+ y_positions = get_start_positions(height, block_size)
82
+ tiles = []
83
+ for y in y_positions:
84
+ for x in x_positions:
85
+ tiles.append(image.crop((x, y, x + block_size, y + block_size)))
86
+ if width > block_size and height > block_size:
87
+ leave_space = min(block_size, width-block_size)
88
+ x_positions = get_start_positions(width-leave_space, block_size, leave_space//2)
89
+ leave_space = min(block_size, height-block_size)
90
+ y_positions = get_start_positions(height-leave_space, block_size, leave_space//2)
91
+ for y in y_positions:
92
+ for x in x_positions:
93
+ tiles.append(image.crop((x, y, x + block_size, y + block_size)))
94
+ return tiles
95
+
96
+ def random_crop(image: Image.Image, size: int = 224, is_pad_black: bool = False) -> Image.Image:
97
+ w, h = image.size
98
+ crop_size = min(w, h, size)
99
+ if w == crop_size:
100
+ left = 0
101
+ else:
102
+ left = random.randint(0, w - crop_size)
103
+ if h == crop_size:
104
+ top = 0
105
+ else:
106
+ top = random.randint(0, h - crop_size)
107
+
108
+ cropped = image.crop((left, top, left + crop_size, top + crop_size))
109
+
110
+ if is_pad_black and crop_size < size:
111
+ padded_img = Image.new("RGB", (size, size), (0, 0, 0))
112
+ offset = ((size - crop_size) // 2, (size - crop_size) // 2)
113
+ padded_img.paste(cropped, offset)
114
+ return padded_img
115
+ else:
116
+ return cropped
117
+
118
+ def _convert_image_to_rgb(image):
119
+ return image.convert("RGB")
120
+
121
+
122
+ def clip_transform_allresize(n_px, blur_radius=None, blur_prob=0.5):
123
+ if blur_radius is None:
124
+ return transforms.Compose([
125
+ transforms.Resize((n_px, n_px), interpolation=BICUBIC),
126
+ _convert_image_to_rgb,
127
+ transforms.ToTensor(),
128
+ transforms.Normalize((0.48145466, 0.4578275, 0.40821073), (0.26862954, 0.26130258, 0.27577711)),
129
+ ])
130
+ else:
131
+ return transforms.Compose([
132
+ transforms.Resize((n_px, n_px), interpolation=BICUBIC),
133
+ transforms.RandomApply(
134
+ [transforms.GaussianBlur(kernel_size=5, sigma=blur_radius)], p=blur_prob # (0.1, 3)
135
+ ),
136
+ _convert_image_to_rgb,
137
+ transforms.ToTensor(),
138
+ transforms.Normalize((0.48145466, 0.4578275, 0.40821073), (0.26862954, 0.26130258, 0.27577711)),
139
+ ])
140
+
141
+ def clip_transform(n_px, blur_radius=None, blur_prob=0.5):
142
+ if blur_radius is None:
143
+ return transforms.Compose([
144
+ transforms.Resize(n_px, interpolation=BICUBIC),
145
+ transforms.CenterCrop(n_px),
146
+ _convert_image_to_rgb,
147
+ transforms.ToTensor(),
148
+ transforms.Normalize((0.48145466, 0.4578275, 0.40821073), (0.26862954, 0.26130258, 0.27577711)),
149
+ ])
150
+ else:
151
+ return transforms.Compose([
152
+ transforms.Resize(n_px, interpolation=BICUBIC),
153
+ transforms.CenterCrop(n_px),
154
+ transforms.RandomApply(
155
+ [transforms.GaussianBlur(kernel_size=5, sigma=blur_radius)], p=blur_prob # (0.1, 3)
156
+ ),
157
+ _convert_image_to_rgb,
158
+ transforms.ToTensor(),
159
+ transforms.Normalize((0.48145466, 0.4578275, 0.40821073), (0.26862954, 0.26130258, 0.27577711)),
160
+ ])
161
+
162
+ class JpegAugment():
163
+ def __init__(self, quality=None, p=1, seed=42):
164
+ self.local_random = random.Random(seed)
165
+ self.quality = quality
166
+ self.p = p
167
+
168
+ def __call__(self, img):
169
+ if self.quality is None:
170
+ return img
171
+ if self.local_random.random() < self.p:
172
+ buffer = io.BytesIO()
173
+ if self.quality == -1:
174
+ img.save(buffer, format='JPEG', quality=self.local_random.randint(30, 100))
175
+ else:
176
+ if isinstance(self.quality, tuple):
177
+ img.save(buffer, format='JPEG', quality=self.local_random.randint(*self.quality))
178
+ else:
179
+ img.save(buffer, format='JPEG', quality=self.quality)
180
+ buffer.seek(0)
181
+ return Image.open(buffer).convert('RGB')
182
+ return img
183
+
184
+ def webp_compress(img, quality=80, method=4):
185
+ buffer = io.BytesIO()
186
+ img.save(buffer, format='WEBP', quality=quality, method=method)
187
+ buffer.seek(0)
188
+ return Image.open(buffer).convert('RGB')
189
+
190
+ # Image extensions supported when scanning real/fake dirs (case-insensitive).
191
+ # Missing .JPG / .webp etc. can cause real folder to show 0 images if only those exist.
192
+ IMG_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tif', '.tiff', '.ppm')
193
+
194
+
195
+ def find_files_with_extensions(directory, extensions=None, max_num=None):
196
+ """Find image paths under directory. If extensions is None, use IMG_EXTENSIONS (case-insensitive)."""
197
+ if extensions is None:
198
+ extensions = IMG_EXTENSIONS
199
+ ext_lower = tuple(e.lower() for e in extensions)
200
+ matching_files = []
201
+ check_num = 0
202
+ for root, dirs, files in os.walk(directory):
203
+ for file in files:
204
+ if file.lower().endswith(ext_lower):
205
+ matching_files.append(os.path.join(root, file))
206
+ check_num += 1
207
+ if max_num is not None and check_num >= max_num:
208
+ return matching_files
209
+ return matching_files
210
+
211
+ class TiledRFDataset(Dataset):
212
+ """ Dataset -> (img, label)"""
213
+ def __init__(self, real_dirs:list, fake_dirs:list, tile_size=224, set_size=None, shuffle_seed=42, balance=True,
214
+ jpeg_augment=None, jpeg_p=0.1,
215
+ webp_augment=None, webp_p=0.1,
216
+ resize_augment=None, resize_p=0.5,
217
+ blur_augment=None, blur_p=0.5,
218
+ default_train_tiles=4, is_train=False):
219
+ self.set_size = set_size
220
+ self.shuffle_seed = shuffle_seed
221
+ self.samples = []
222
+ self.real_samples = []
223
+ self.fake_samples = []
224
+ self.tile_size=tile_size
225
+ self.transform = clip_transform(tile_size, blur_augment, blur_p)
226
+ self.default_train_tiles = default_train_tiles
227
+ self.is_train = is_train
228
+
229
+ self.jpeg_augment=jpeg_augment
230
+ self.jpeg_p = jpeg_p
231
+ self.JpegEnhance = JpegAugment(quality=jpeg_augment, p=jpeg_p, seed=shuffle_seed)
232
+ self.webp_augment = webp_augment
233
+ self.webp_p = webp_p
234
+ self.resize_augment = resize_augment
235
+ self.resize_p = resize_p
236
+
237
+ for p in real_dirs:
238
+ find_paths_real = find_files_with_extensions(p)
239
+ self.real_samples += [(f, 0) for f in find_paths_real]
240
+ for p in fake_dirs:
241
+ find_paths_fake = find_files_with_extensions(p)
242
+ self.fake_samples += [(f, 1) for f in find_paths_fake]
243
+ if balance:
244
+ target_len = min(len(self.real_samples), len(self.fake_samples))
245
+ self.real_samples = self.real_samples[:target_len]
246
+ self.fake_samples = self.fake_samples[:target_len]
247
+ self.samples = self.real_samples + self.fake_samples
248
+ # if self.set_size is not None and len(self.samples) < self.set_size:
249
+ # n_repeats = math.ceil(self.set_size / len(self.samples))
250
+ # self.samples = (self.samples * n_repeats)[:self.set_size]
251
+ if self.set_size is not None and len(self.samples) > self.set_size:
252
+ local_random = random.Random(self.shuffle_seed)
253
+ self.samples = local_random.sample(self.samples, self.set_size)
254
+ def __len__(self):
255
+ return len(self.samples)
256
+
257
+ def _get_train_item(self, idx):
258
+ path, label = self.samples[idx]
259
+ try:
260
+ img = Image.open(path).convert('RGB')
261
+ except Exception as e:
262
+ print(f"Skipping corrupted image: {path}")
263
+ return self.__getitem__((idx + 1) % len(self.samples))
264
+ if self.resize_augment is not None:
265
+ if self.resize_augment == -1:
266
+ img = resize_with_random_scale(img, p=self.resize_p)
267
+ else:
268
+ if isinstance(self.resize_augment, tuple):
269
+ img = resize_with_random_scale(img, p=self.resize_p, scale_range=self.resize_augment)
270
+ else:
271
+ img = resize_with_random_scale(img, p=self.resize_p, scale_range=(self.resize_augment, self.resize_augment))
272
+ if self.jpeg_augment is not None:
273
+ img = self.JpegEnhance(img)
274
+
275
+ # crops_one_side = 2
276
+ # img = pad_crop_image(img, crops_one_side*self.tile_size, crops_one_side*self.tile_size)
277
+ # tiles = crop_image_to_tiles(img, self.tile_size)[:crops_one_side**2]
278
+ tiles = []
279
+ for i in range(self.default_train_tiles): # 10
280
+ tiles.append(random_crop(img, self.tile_size, is_pad_black=False))
281
+ img_resize = self.transform(img)
282
+ img_list = [img_resize]
283
+ for tile in tiles:
284
+ img_list.append(self.transform(tile))
285
+ return img_list, img, label
286
+
287
+ def _get_test_item(self, idx):
288
+ path, label = self.samples[idx]
289
+ try:
290
+ img = Image.open(path).convert('RGB')
291
+ except Exception as e:
292
+ print(f"Skipping corrupted image: {path}")
293
+ return self.__getitem__((idx + 1) % len(self.samples))
294
+ if self.resize_augment is not None:
295
+ if self.resize_augment == -1:
296
+ img = resize_with_random_scale(img, p=self.resize_p)
297
+ else:
298
+ if isinstance(self.resize_augment, tuple):
299
+ img = resize_with_random_scale(img, p=self.resize_p, scale_range=self.resize_augment)
300
+ else:
301
+ img = resize_with_random_scale(img, p=self.resize_p, scale_range=(self.resize_augment, self.resize_augment))
302
+ if self.jpeg_augment is not None:
303
+ img = self.JpegEnhance(img)
304
+ if self.webp_augment is not None:
305
+ if random.random() < self.webp_p:
306
+ if isinstance(self.webp_augment, tuple):
307
+ img = webp_compress(img, quality=random.randint(*self.webp_augment))
308
+ else:
309
+ img = webp_compress(img, quality=self.webp_augment)
310
+ # testtime batchsize=1
311
+ tiles = crop_image_to_tiles(img, self.tile_size)
312
+ img_resize = self.transform(img)
313
+ img_list = [img_resize]
314
+ for tile in tiles:
315
+ img_list.append(self.transform(tile))
316
+ return img_list, img, label, path
317
+
318
+ def __getitem__(self, idx):
319
+ if self.is_train:
320
+ return self._get_train_item(idx)
321
+ else:
322
+ return self._get_test_item(idx)
323
+
324
+
325
+ def collate_img_list_to_tensor_train(batch):
326
+ """
327
+ batch = list of (img_list, label)
328
+ - img_list: List of N Tensor[B, 3, H, W]
329
+ return:
330
+ - images: Tensor[B, N, 3, H, W]
331
+ - labels: Tensor[B]
332
+ """
333
+ img_lists, raw_imgs, labels = zip(*batch) # len = B
334
+ # img_lists: list of length B, each is List[N x (3,H,W)]
335
+ stacked = torch.stack([torch.stack(imgs) for imgs in img_lists]) # (B, N, 3, H, W)
336
+ raw_imgs = list(raw_imgs)
337
+ labels = torch.tensor(labels)
338
+ return stacked, raw_imgs, labels
339
+
340
+ def collate_img_list_to_tensor_test(batch):
341
+ """
342
+ batch = list of (img_list, label)
343
+ - img_list: List of N Tensor[B, 3, H, W]
344
+ return:
345
+ - images: Tensor[B, N, 3, H, W]
346
+ - labels: Tensor[B]
347
+ """
348
+ img_lists, raw_imgs, labels, paths = zip(*batch) # len = B
349
+ # img_lists: list of length B, each is List[N x (3,H,W)]
350
+ stacked = torch.stack([torch.stack(imgs) for imgs in img_lists]) # (B, N, 3, H, W)
351
+ raw_imgs = list(raw_imgs)
352
+ labels = torch.tensor(labels)
353
+ return stacked, raw_imgs, labels, paths
354
+
355
+
356
+ if __name__ == '__main__':
357
+ block_size = 224
358
+ width = 512
359
+ leave_space = min(block_size, width-block_size)
360
+ x_positions = get_start_positions(width-leave_space, block_size, leave_space//2)
361
+ print(leave_space, x_positions)
362
+
weights/epoch_20.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32e9fa2e38e04d6d0221c844d650f0d45f7235bbf898b5ad665543d8be580031
3
+ size 62249626