Release original LTF v3.3 epoch 20 inference model
Browse files- .gitattributes +1 -34
- CLIP_LICENSE +22 -0
- backbones/ViT-L-14.pt +3 -0
- configs/eval.yaml +58 -0
- inference.py +66 -0
- ltf_final/__init__.py +5 -0
- ltf_final/detector.py +125 -0
- mil_module/__init__.py +31 -0
- mil_module/cross_shuffle.py +145 -0
- mil_module/mil_core.py +219 -0
- mil_module/mil_head.py +211 -0
- mil_module/mil_head_beta.py +212 -0
- model/LTF.py +206 -0
- model/LTF_v2.py +427 -0
- model/LTF_v3_3.py +461 -0
- model/__init__.py +1 -0
- release_manifest.json +80 -0
- requirements.txt +9 -0
- results/genimage8_logged_metrics.csv +9 -0
- results/summary.json +13 -0
- utils/__init__.py +0 -0
- utils/tile_dataset_srm.py +362 -0
- weights/epoch_20.pth +3 -0
.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 |
-
*.
|
| 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
|