Gertlek commited on
Commit
b4bedeb
·
verified ·
1 Parent(s): deab2fd

Upload clean DetectiveSAMv2 inference bundle

Browse files
.gitattributes CHANGED
@@ -1,35 +1,10 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ sam2configs/*.pt filter=lfs diff=lfs merge=lfs -text
2
+ checkpoints/*.pth filter=lfs diff=lfs merge=lfs -text
3
+ demo/cocoglide/source/banana_28809.png filter=lfs diff=lfs merge=lfs -text
4
+ demo/cocoglide/source/train_221213.png filter=lfs diff=lfs merge=lfs -text
5
+ demo/cocoglide/target/banana_28809.png filter=lfs diff=lfs merge=lfs -text
6
+ demo/cocoglide/target/train_221213.png filter=lfs diff=lfs merge=lfs -text
7
+ demo/flux_test/source/548.png filter=lfs diff=lfs merge=lfs -text
8
+ demo/flux_test/target/548.png filter=lfs diff=lfs merge=lfs -text
9
+ demo/qwen_test/source/166.png filter=lfs diff=lfs merge=lfs -text
10
+ demo/qwen_test/target/166.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .venv/
5
+ venv/
6
+ .pytest_cache/
7
+ outputs/
8
+ checkpoints/*.pth
9
+ sam2configs/sam2.1_hiera_base_plus.pt
README.md ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DetectiveSAMv2
2
+
3
+ DetectiveSAMv2 is an inference-only image forgery localization bundle built around SAM2. The full runnable bundle with model weights is hosted on Hugging Face:
4
+
5
+ - https://huggingface.co/Gertlek/DetectiveSAMv2
6
+
7
+ ## What is bundled
8
+
9
+ - Inference checkpoint configs under `checkpoints/`
10
+ - SAM2 config under `sam2configs/`
11
+ - Poster demo pairs under `demo/cocoglide/`, `demo/flux_test/`, and `demo/qwen_test/`
12
+ - A drop-in single-image slot at `demo/user_image/demo_input.png`
13
+
14
+ Built-in checkpoint aliases:
15
+
16
+ - `detective_sam_v2`
17
+ - `detective_sam`
18
+ - `detective_sam_sota`
19
+
20
+ The v2 bundle ships `detective_sam_v2`. The v1 aliases are retained for compatibility if those older checkpoint files are placed under `checkpoints/`.
21
+
22
+ ## Architecture
23
+
24
+ This branch ports the v2 inference architecture from the training workspace while keeping the package layout, CLIs, and regression-test structure intact.
25
+
26
+ Main changes:
27
+
28
+ - Evidence features built from unadapted target features, raw perturbation-stream features, and adapted target deltas.
29
+ - Spatial cross-attention feature adapters for flexible information sharing across target and perturbation streams.
30
+ - Transformer evidence mask adapter for direct prompt-mask prediction.
31
+ - V1 and V2 adapter code split into `adapters_v1.py` and `adapters_v2.py`; `adapters.py` remains a compatibility re-export.
32
+ - Three perturbation streams by default: Gaussian blur, JPEG compression, and Gaussian noise.
33
+ - JSON and YAML checkpoint sidecar support, including training checkpoint containers with `model` or `ema` keys.
34
+
35
+ The `detective_sam_v2` alias expects a checkpoint at `checkpoints/detective_sam_v2.pth` with the sidecar config included in this repo.
36
+
37
+ ### Benchmark Scores
38
+
39
+ Scores below are IoU / F1 in percent for the 10-epoch v2 checkpoint `dsamv2_best6info_10ep_20260623_060047/best_model.pth`. Paper baseline numbers are from the Detective SAM ICLR 2026 paper tables for the original Detective SAM model.
40
+
41
+ | Dataset | Split | Samples | DetectiveSAMv2 | Paper Detective SAM | Delta |
42
+ | --- | --- | ---: | ---: | ---: | ---: |
43
+ | MagicBrush | trained on | 528 | 62.73 / 74.38 | 46.48 / 57.55 | +16.25 / +16.83 |
44
+ | SIDA | trained on | 6,911 | 55.34 / 65.72 | 54.55 / 65.29 | +0.79 / +0.43 |
45
+ | UltraEdit 10k | trained on | 10,000 | 67.03 / 76.71 | 27.74 / 35.54 | +39.29 / +41.17 |
46
+ | CoCoGLIDE | not trained on | 512 | 47.17 / 58.83 | 44.74 / 51.50 | +2.43 / +7.33 |
47
+ | AutoSplice | not trained on | 3,621 | 58.39 / 71.40 | 46.90 / 60.30 | +11.49 / +11.10 |
48
+ | NanoBanana | not trained on | 84 | 33.81 / 46.79 | 19.34 / 20.77 | +14.47 / +26.02 |
49
+ | FLUX-Bench | not trained on | 647 | 46.10 / 58.02 | 18.70 / 21.28 | +27.40 / +36.74 |
50
+ | QWEN-Bench | not trained on | 194 | 46.32 / 57.94 | 20.41 / 22.29 | +25.91 / +35.65 |
51
+
52
+ Grouped results from the same run:
53
+
54
+ | Group | Samples | Weighted IoU / F1 | Simple IoU / F1 |
55
+ | --- | ---: | ---: | ---: |
56
+ | Trained-on | 17,439 | 62.26 / 72.28 | 61.70 / 72.27 |
57
+ | Not-trained-on | 5,058 | 54.81 / 67.49 | 46.36 / 58.60 |
58
+ | Overall | 22,497 | 60.59 / 71.20 | 52.11 / 63.72 |
59
+
60
+ The not-trained-on group follows the requested reporting split and includes NanoBanana, CoCoGLIDE, AutoSplice, QWEN-Bench, and FLUX-Bench.
61
+
62
+ ## Setup
63
+
64
+ ```bash
65
+ python -m venv .venv
66
+ source .venv/bin/activate
67
+ pip install -r requirements.txt
68
+ ```
69
+
70
+ Then download the large weights from Hugging Face:
71
+
72
+ ```bash
73
+ pip install -U huggingface_hub
74
+ hf download Gertlek/DetectiveSAMv2 \
75
+ checkpoints/detective_sam_v2.pth \
76
+ sam2configs/sam2.1_hiera_base_plus.pt \
77
+ --local-dir .
78
+ ```
79
+
80
+ The expected checkpoint paths are also documented in `checkpoints/README.md` and `sam2configs/README.md`.
81
+
82
+ ## Hugging Face Usage
83
+
84
+ For the simplest setup, clone the Hugging Face repo directly:
85
+
86
+ ```bash
87
+ git lfs install
88
+ git clone https://huggingface.co/Gertlek/DetectiveSAMv2
89
+ cd DetectiveSAMv2
90
+ python -m venv .venv
91
+ source .venv/bin/activate
92
+ pip install -r requirements.txt
93
+ python -m detectivesam_inference.predict \
94
+ --output-dir outputs/poster_baseline
95
+ ```
96
+
97
+ ## Poster Demo Flows
98
+
99
+ ### 1. Live single-image demo
100
+
101
+ Place your image at `demo/user_image/demo_input.png`, then run:
102
+
103
+ ```bash
104
+ python -m detectivesam_inference.predict \
105
+ --output-dir outputs/poster_user_image
106
+ ```
107
+
108
+ In this mode the CLI reuses the target image as its own source reference so the demo stays runnable with a single image.
109
+
110
+ ### 2. Bundled baseline example
111
+
112
+ If `demo/user_image/demo_input.png` is absent, the default `predict` command falls back to the bundled CocoGlide sample `banana_28809`.
113
+
114
+ ```bash
115
+ python -m detectivesam_inference.predict \
116
+ --output-dir outputs/poster_baseline
117
+ ```
118
+
119
+ ### 3. Bundled SOTA examples
120
+
121
+ Flux example:
122
+
123
+ ```bash
124
+ python -m detectivesam_inference.predict \
125
+ --checkpoint detective_sam_v2 \
126
+ --source demo/flux_test/source/548.png \
127
+ --target demo/flux_test/target/548.png \
128
+ --mask demo/flux_test/mask/548.png \
129
+ --output-dir outputs/poster_flux
130
+ ```
131
+
132
+ Qwen example:
133
+
134
+ ```bash
135
+ python -m detectivesam_inference.predict \
136
+ --checkpoint detective_sam_v2 \
137
+ --source demo/qwen_test/source/166.png \
138
+ --target demo/qwen_test/target/166.png \
139
+ --mask demo/qwen_test/mask/166.png \
140
+ --output-dir outputs/poster_qwen
141
+ ```
142
+
143
+ ### 4. Bundled CocoGlide subset sweep
144
+
145
+ Use this to evaluate the bundled banana and train CocoGlide demo pairs.
146
+
147
+ ```bash
148
+ python -m detectivesam_inference.evaluate \
149
+ --checkpoint detective_sam_v2 \
150
+ --dataset-root demo/cocoglide \
151
+ --output-dir outputs/poster_eval_cocoglide \
152
+ --num-visualizations 2
153
+ ```
154
+
155
+ ## Outputs
156
+
157
+ Each `predict` run writes a compact set of visual artifacts plus a JSON summary:
158
+
159
+ - `<name>_comparison.png`
160
+ - `<name>_probability.png`
161
+ - `<name>_pred_mask.png`
162
+ - `<name>_pred_overlay.png`
163
+ - `<name>_summary.json`
164
+
165
+ If a ground-truth mask is provided, the run also saves:
166
+
167
+ - `<name>_gt_mask.png`
168
+ - `<name>_gt_overlay.png`
169
+
170
+ The `evaluate` command writes `summary.json` plus a few visualization examples under `visualizations/`.
171
+
172
+ ## Notes
173
+
174
+ - The runtime selects `cuda` automatically when available and otherwise runs on CPU.
175
+ - Checkpoint settings come from YAML or JSON sidecars in `checkpoints/`; you only need the alias or checkpoint path.
176
+ - The Hugging Face repo bundles the v2 `.pth` checkpoint and SAM2 `.pt` weight file.
177
+ - This repo does not include training code or training-only dependencies.
checkpoints/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DetectiveSAMv2 Checkpoints
2
+
3
+ The Hugging Face bundle ships the v2 `.pth` checkpoint file.
4
+
5
+ The full runnable bundle is hosted on Hugging Face:
6
+
7
+ - https://huggingface.co/Gertlek/DetectiveSAMv2
8
+
9
+ To run inference from a lightweight checkout, download this file from Hugging Face into this directory:
10
+
11
+ - `detective_sam_v2.pth`
12
+
13
+ The small YAML sidecar in this directory is kept in Git so the `detective_sam_v2` alias resolves once the `.pth` file is present locally.
checkpoints/detective_sam_v2_params.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ img_size: 512
2
+ prompt_dim: 96
3
+ downscale: 8
4
+ dropout_rate: 0.1
5
+ perturbation_type: gaussian_blur+jpeg_compression+gaussian_noise
6
+ perturbation_intensity: 0.5
7
+ sam_config_file: sam2.1_hiera_b+.yaml
8
+ sam_checkpoint: sam2configs/sam2.1_hiera_base_plus.pt
9
+ adapter_type: spatial_cross_attention
10
+ mask_adapter_type: transformer
demo/cocoglide/mask/banana_28809.png ADDED
demo/cocoglide/mask/train_221213.png ADDED
demo/cocoglide/source/banana_28809.png ADDED

Git LFS Details

  • SHA256: d842bef21af9d5468aeed66f92a21a9b893e880583f93688cb443221124bc5c3
  • Pointer size: 131 Bytes
  • Size of remote file: 108 kB
demo/cocoglide/source/train_221213.png ADDED

Git LFS Details

  • SHA256: 3c1a76bc7e58e6499cce72b3fdb7d666d3ac94244279508f318de36dc05b4ffb
  • Pointer size: 131 Bytes
  • Size of remote file: 146 kB
demo/cocoglide/target/banana_28809.png ADDED

Git LFS Details

  • SHA256: d86a8b5673c6647354290bc46287762b817ae2f296e26fcafce92f6ef88fcceb
  • Pointer size: 131 Bytes
  • Size of remote file: 104 kB
demo/cocoglide/target/train_221213.png ADDED

Git LFS Details

  • SHA256: 6a427942626ad756626a851d3ed9f8d700e1a43fda02bd33c5c64034b55dcb99
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
demo/flux_test/mask/548.png ADDED
demo/flux_test/source/548.png ADDED

Git LFS Details

  • SHA256: 971bde6a9467168f20bb32b1e8d6aaf98cf0a5a92d898f3cfdbac606a57be0d1
  • Pointer size: 131 Bytes
  • Size of remote file: 771 kB
demo/flux_test/target/548.png ADDED

Git LFS Details

  • SHA256: d8e3e3f6366e54dcf90075a717fabe85f3f9cb7c537c97a1d54b8a5d0561b9a3
  • Pointer size: 131 Bytes
  • Size of remote file: 684 kB
demo/qwen_test/mask/166.png ADDED
demo/qwen_test/source/166.png ADDED

Git LFS Details

  • SHA256: 30e80f7c5e4a80f5aae825177d70a2ef48f9541092480856ca5a1469ac10e0af
  • Pointer size: 132 Bytes
  • Size of remote file: 1.11 MB
demo/qwen_test/target/166.png ADDED

Git LFS Details

  • SHA256: 337a5bf677fb71ee9be3a831d64d7e62d78315a34298397f021b0732acd897e3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
demo/user_image/README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User Image Demo
2
+
3
+ Place a single demo image here as:
4
+
5
+ - `demo_input.png`
6
+
7
+ Then run:
8
+
9
+ ```bash
10
+ python -m detectivesam_inference.predict
11
+ ```
12
+
13
+ When only a target image is available, the CLI reuses that image as its own reference source. That keeps the demo runnable, but it is not the canonical pairwise evaluation mode the model was trained for.
14
+
detectivesam_inference/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from detectivesam_inference.checkpoint import InferenceConfig
2
+ from detectivesam_inference.runtime import DetectiveSAMRunner, PredictionResult
3
+
4
+ __all__ = ["DetectiveSAMRunner", "InferenceConfig", "PredictionResult"]
detectivesam_inference/checkpoint.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ DEFAULT_CHECKPOINT = Path("checkpoints/detective_sam_v2.pth")
12
+ CHECKPOINT_ALIASES = {
13
+ "detective_sam": Path("checkpoints/model_epoch22_batch999_score1.1114.pth"),
14
+ "detective_sam_sota": Path("checkpoints/detective_sam_sota.pth"),
15
+ "detective_sam_v2": DEFAULT_CHECKPOINT,
16
+ }
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class InferenceConfig:
21
+ img_size: int
22
+ prompt_dim: int
23
+ downscale: int
24
+ dropout_rate: float
25
+ perturbation_type: str
26
+ perturbation_intensity: float
27
+ sam_config_file: str
28
+ sam_checkpoint: str
29
+ adapter_type: str = "conv"
30
+ mask_adapter_type: str = "coarse"
31
+
32
+ @property
33
+ def max_streams(self) -> int:
34
+ return count_perturbation_streams(self.perturbation_type)
35
+
36
+
37
+ def resolve_checkpoint_path(checkpoint_value: str | Path | None, repo_root: str | Path) -> Path:
38
+ repo_root = Path(repo_root)
39
+ if checkpoint_value is None:
40
+ return repo_root / DEFAULT_CHECKPOINT
41
+
42
+ checkpoint_str = str(checkpoint_value)
43
+ if checkpoint_str in CHECKPOINT_ALIASES:
44
+ return repo_root / CHECKPOINT_ALIASES[checkpoint_str]
45
+
46
+ checkpoint_path = Path(checkpoint_value)
47
+ if checkpoint_path.is_absolute():
48
+ return checkpoint_path
49
+ if checkpoint_path.exists():
50
+ return checkpoint_path.resolve()
51
+
52
+ repo_candidate = repo_root / checkpoint_path
53
+ if repo_candidate.exists():
54
+ return repo_candidate
55
+
56
+ aliased_checkpoint = repo_root / "checkpoints" / f"{checkpoint_str}.pth"
57
+ if aliased_checkpoint.exists():
58
+ return aliased_checkpoint
59
+ return repo_candidate
60
+
61
+
62
+ def resolve_repo_path(path_value: str | Path, repo_root: str | Path) -> Path:
63
+ path = Path(path_value)
64
+ if path.is_absolute():
65
+ return path
66
+
67
+ repo_root = Path(repo_root)
68
+ direct = repo_root / path
69
+ if direct.exists():
70
+ return direct
71
+
72
+ sam_config = repo_root / "sam2configs" / path.name
73
+ if sam_config.exists():
74
+ return sam_config
75
+ return direct
76
+
77
+
78
+ def load_inference_config(checkpoint_path: str | Path) -> InferenceConfig:
79
+ params = _load_params_file(checkpoint_path)
80
+ return InferenceConfig(
81
+ img_size=int(_resolve_param(params, "img_size", section="training_config", default=512)),
82
+ prompt_dim=int(
83
+ _resolve_param(
84
+ params,
85
+ "prompt_dim",
86
+ section="model_config",
87
+ default=_resolve_param(params, "prompt", section="model_config", default=128),
88
+ )
89
+ ),
90
+ downscale=int(_resolve_param(params, "downscale", section="model_config", default=16)),
91
+ dropout_rate=float(
92
+ _resolve_param(
93
+ params,
94
+ "dropout_rate",
95
+ section="model_config",
96
+ default=_resolve_param(params, "dropout", section="model_config", default=0.1),
97
+ )
98
+ ),
99
+ perturbation_type=str(_resolve_param(params, "perturbation_type", section="data_config", default="none")),
100
+ perturbation_intensity=float(
101
+ _resolve_param(params, "perturbation_intensity", section="data_config", default=0.0)
102
+ ),
103
+ sam_config_file=str(
104
+ _resolve_param(params, "sam_config_file", section="sam_config", default="sam2.1_hiera_b+.yaml")
105
+ ),
106
+ sam_checkpoint=str(
107
+ _resolve_param(
108
+ params,
109
+ "sam_checkpoint",
110
+ section="sam_config",
111
+ default="sam2configs/sam2.1_hiera_base_plus.pt",
112
+ )
113
+ ),
114
+ adapter_type=str(_resolve_param(params, "adapter_type", section="model_config", default="conv")),
115
+ mask_adapter_type=str(_resolve_param(params, "mask_adapter_type", section="model_config", default="coarse")),
116
+ )
117
+
118
+
119
+ def count_perturbation_streams(perturbation_type: str) -> int:
120
+ if perturbation_type == "none":
121
+ return 0
122
+ if "+" in perturbation_type:
123
+ return len([item for item in perturbation_type.split("+") if item.strip()])
124
+ if "/" in perturbation_type:
125
+ return len([item for item in perturbation_type.split("/") if item.strip()])
126
+ return 1
127
+
128
+
129
+ def _load_params_file(checkpoint_path: str | Path) -> dict[str, Any]:
130
+ checkpoint_path = Path(checkpoint_path)
131
+ candidate_paths = [
132
+ checkpoint_path.with_name(f"{checkpoint_path.stem}_params.yaml"),
133
+ checkpoint_path.with_name(f"{checkpoint_path.stem}_params.yml"),
134
+ checkpoint_path.with_name(f"{checkpoint_path.stem}_params.json"),
135
+ checkpoint_path.parent / "model_params.yaml",
136
+ checkpoint_path.parent / "model_params.yml",
137
+ checkpoint_path.parent / "model_params.json",
138
+ ]
139
+ for candidate in candidate_paths:
140
+ if candidate.exists():
141
+ with candidate.open("r", encoding="utf-8") as handle:
142
+ loaded = json.load(handle) if candidate.suffix == ".json" else yaml.safe_load(handle)
143
+ if not isinstance(loaded, dict):
144
+ raise ValueError(f"Checkpoint params file must deserialize to a mapping: {candidate}")
145
+ return loaded
146
+ raise FileNotFoundError(
147
+ f"Could not find a params file for checkpoint {checkpoint_path}. "
148
+ f"Checked: {', '.join(str(path) for path in candidate_paths)}"
149
+ )
150
+
151
+
152
+ def _resolve_param(
153
+ params: dict[str, Any],
154
+ key: str,
155
+ *,
156
+ section: str,
157
+ default: Any,
158
+ ) -> Any:
159
+ if key in params:
160
+ return params[key]
161
+ return params.get(section, {}).get(key, default)
detectivesam_inference/dataset.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import cv2
8
+ import numpy as np
9
+ import torch
10
+ import torchvision.transforms.functional as TF
11
+ from PIL import Image
12
+ from sam2.utils.transforms import SAM2Transforms
13
+ from torch.utils.data import Dataset
14
+
15
+ from detectivesam_inference.perturbations import (
16
+ add_gaussian_noise_deterministic,
17
+ apply_blur_to_image_tensor,
18
+ apply_jpeg_compression_to_tensor,
19
+ )
20
+
21
+
22
+ LEGACY_CONTRASTIVE_FLAG = False
23
+
24
+
25
+ @dataclass
26
+ class PreparedSample:
27
+ name: str
28
+ source_path: Path
29
+ target_path: Path
30
+ mask_path: Path | None
31
+ source_image: Image.Image
32
+ target_image: Image.Image
33
+ orig: torch.Tensor
34
+ streams: list[torch.Tensor]
35
+ mask: torch.Tensor | None
36
+
37
+
38
+ def parse_perturbation_types(perturbation_type: str) -> list[str]:
39
+ if perturbation_type == "none":
40
+ return []
41
+ if "+" in perturbation_type:
42
+ return [item.strip() for item in perturbation_type.split("+")]
43
+ if "/" in perturbation_type:
44
+ return [item.strip() for item in perturbation_type.split("/")]
45
+ return [perturbation_type.strip()]
46
+
47
+
48
+ def compute_perturbation_params(perturbation_intensity: float) -> dict[str, float | int]:
49
+ return {
50
+ "blur_sigma": perturbation_intensity * 2.0,
51
+ "jpeg_quality": max(10, int(95 - (perturbation_intensity * 56.67))),
52
+ "noise_std": perturbation_intensity * 0.2,
53
+ }
54
+
55
+
56
+ def create_combined_mask(
57
+ mask_rgba: np.ndarray,
58
+ ) -> np.ndarray:
59
+ if mask_rgba.ndim == 2:
60
+ return (mask_rgba // 255).astype(np.uint8)
61
+
62
+ if mask_rgba.ndim == 3 and mask_rgba.shape[2] == 4:
63
+ alpha = mask_rgba[:, :, 3]
64
+ alpha_is_opaque = alpha.sum() == alpha.size * 255
65
+ if alpha_is_opaque:
66
+ foreground = (mask_rgba[:, :, 0] > 0).astype(np.uint8)
67
+ return cv2.resize(foreground, (512, 512), interpolation=cv2.INTER_NEAREST).astype(np.uint8)
68
+
69
+ _, binary = cv2.threshold(alpha, 0, 255, cv2.THRESH_BINARY)
70
+ return (1 - (binary // 255)).astype(np.uint8)
71
+
72
+ return (mask_rgba[:, :, 0] > 0).astype(np.uint8)
73
+
74
+
75
+ def resize_triplet(
76
+ source_image: Image.Image,
77
+ target_image: Image.Image,
78
+ mask_image: Image.Image | None,
79
+ img_size: int,
80
+ ) -> tuple[Image.Image, Image.Image, Image.Image | None]:
81
+ source_resized = source_image.resize((img_size, img_size), Image.BILINEAR)
82
+ target_resized = target_image.resize((img_size, img_size), Image.BILINEAR)
83
+ if mask_image is None:
84
+ return source_resized, target_resized, None
85
+ return source_resized, target_resized, mask_image.resize((img_size, img_size), Image.NEAREST)
86
+
87
+
88
+ def build_streams(
89
+ target_image: Image.Image,
90
+ perturbation_type: str,
91
+ perturbation_intensity: float,
92
+ seed: int,
93
+ ) -> list[torch.Tensor]:
94
+ perturbations = parse_perturbation_types(perturbation_type)
95
+ params = compute_perturbation_params(perturbation_intensity)
96
+ orig_tensor = TF.to_tensor(target_image)
97
+
98
+ streams: list[torch.Tensor] = []
99
+ for perturbation in perturbations:
100
+ if perturbation == "gaussian_blur":
101
+ streams.append(apply_blur_to_image_tensor(orig_tensor, sigma=float(params["blur_sigma"])))
102
+ elif perturbation == "jpeg_compression":
103
+ streams.append(apply_jpeg_compression_to_tensor(orig_tensor, quality=int(params["jpeg_quality"])))
104
+ elif perturbation == "gaussian_noise":
105
+ streams.append(
106
+ add_gaussian_noise_deterministic(
107
+ orig_tensor,
108
+ std=float(params["noise_std"]),
109
+ seed=seed + len(streams),
110
+ )
111
+ )
112
+ else:
113
+ raise ValueError(f"Unsupported perturbation type: {perturbation}")
114
+ return streams
115
+
116
+
117
+ def build_sample_seed(
118
+ source_path: Path,
119
+ target_path: Path,
120
+ mask_path: Path | None,
121
+ perturbation_type: str,
122
+ perturbation_intensity: float,
123
+ ) -> int:
124
+ sample_key = "|".join(
125
+ [
126
+ source_path.parent.parent.name,
127
+ source_path.stem,
128
+ target_path.parent.parent.name,
129
+ target_path.stem,
130
+ mask_path.stem if mask_path is not None else "no-mask",
131
+ perturbation_type,
132
+ f"{perturbation_intensity:.8f}",
133
+ str(LEGACY_CONTRASTIVE_FLAG),
134
+ ]
135
+ )
136
+ digest = hashlib.sha256(sample_key.encode("utf-8")).digest()
137
+ return int.from_bytes(digest[:8], byteorder="big", signed=False) % (2**31)
138
+
139
+
140
+ def prepare_sample(
141
+ source_path: str | Path,
142
+ target_path: str | Path,
143
+ mask_path: str | Path | None,
144
+ img_size: int,
145
+ perturbation_type: str,
146
+ perturbation_intensity: float,
147
+ ) -> PreparedSample:
148
+ source_path = Path(source_path)
149
+ target_path = Path(target_path)
150
+ mask_path = Path(mask_path) if mask_path is not None else None
151
+
152
+ source_image = Image.open(source_path).convert("RGB")
153
+ target_image = Image.open(target_path).convert("RGB")
154
+ mask_image = Image.open(mask_path) if mask_path is not None else None
155
+ source_image, target_image, mask_image = resize_triplet(source_image, target_image, mask_image, img_size)
156
+
157
+ sample_seed = build_sample_seed(
158
+ source_path=source_path,
159
+ target_path=target_path,
160
+ mask_path=mask_path,
161
+ perturbation_type=perturbation_type,
162
+ perturbation_intensity=perturbation_intensity,
163
+ )
164
+ transforms = SAM2Transforms(resolution=img_size, mask_threshold=0.0)
165
+
166
+ orig_tensor = TF.to_tensor(target_image)
167
+ orig = transforms.transforms(orig_tensor).unsqueeze(0).squeeze(0)
168
+ streams_raw = build_streams(
169
+ target_image=target_image,
170
+ perturbation_type=perturbation_type,
171
+ perturbation_intensity=perturbation_intensity,
172
+ seed=sample_seed,
173
+ )
174
+ streams = [transforms.transforms(stream).unsqueeze(0).squeeze(0) for stream in streams_raw]
175
+
176
+ mask_tensor = None
177
+ if mask_image is not None:
178
+ binary_mask = create_combined_mask(
179
+ mask_rgba=np.array(mask_image),
180
+ )
181
+ if binary_mask.shape != (img_size, img_size):
182
+ binary_mask = cv2.resize(binary_mask, (img_size, img_size), interpolation=cv2.INTER_NEAREST)
183
+ mask_tensor = torch.tensor(binary_mask, dtype=torch.float32).unsqueeze(0)
184
+
185
+ return PreparedSample(
186
+ name=target_path.stem,
187
+ source_path=source_path,
188
+ target_path=target_path,
189
+ mask_path=mask_path,
190
+ source_image=source_image,
191
+ target_image=target_image,
192
+ orig=orig,
193
+ streams=streams,
194
+ mask=mask_tensor,
195
+ )
196
+
197
+
198
+ class PairDataset(Dataset):
199
+ def __init__(
200
+ self,
201
+ root_dir: str | Path,
202
+ img_size: int,
203
+ perturbation_type: str,
204
+ perturbation_intensity: float,
205
+ max_samples: int | None = None,
206
+ ) -> None:
207
+ self.root_dir = Path(root_dir)
208
+ self.source_dir = self.root_dir / "source"
209
+ self.target_dir = self.root_dir / "target"
210
+ self.mask_dir = self.root_dir / "mask"
211
+ self.img_size = img_size
212
+ self.perturbation_type = perturbation_type
213
+ self.perturbation_intensity = perturbation_intensity
214
+
215
+ if not self.source_dir.exists() or not self.target_dir.exists():
216
+ raise FileNotFoundError(f"{root_dir} must contain source/ and target/ directories")
217
+
218
+ target_files = sorted(
219
+ path
220
+ for path in self.target_dir.iterdir()
221
+ if path.suffix.lower() in {".png", ".jpg", ".jpeg"}
222
+ )
223
+ if max_samples is not None:
224
+ target_files = target_files[:max_samples]
225
+ self.target_files = target_files
226
+
227
+ def __len__(self) -> int:
228
+ return len(self.target_files)
229
+
230
+ def __getitem__(self, index: int) -> PreparedSample:
231
+ target_path = self.target_files[index]
232
+ source_path = self.source_dir / target_path.name
233
+ if not source_path.exists():
234
+ png_fallback = self.source_dir / f"{target_path.stem}.png"
235
+ jpg_fallback = self.source_dir / f"{target_path.stem}.jpg"
236
+ source_path = png_fallback if png_fallback.exists() else jpg_fallback
237
+ if not source_path.exists():
238
+ raise FileNotFoundError(f"Could not find source image for {target_path.name}")
239
+
240
+ mask_candidate = self.mask_dir / f"{target_path.stem}.png"
241
+ mask_path = mask_candidate if mask_candidate.exists() else None
242
+ return prepare_sample(
243
+ source_path=source_path,
244
+ target_path=target_path,
245
+ mask_path=mask_path,
246
+ img_size=self.img_size,
247
+ perturbation_type=self.perturbation_type,
248
+ perturbation_intensity=self.perturbation_intensity,
249
+ )
detectivesam_inference/evaluate.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from tqdm import tqdm
8
+
9
+ from detectivesam_inference.dataset import PairDataset
10
+ from detectivesam_inference.metrics import compute_f1, compute_iou, summarize_results
11
+ from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
12
+ from detectivesam_inference.visualization import save_prediction_outputs
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ repo_root = get_repo_root()
17
+ parser = argparse.ArgumentParser(description="Evaluate DetectiveSAM on a dataset root with source/target/mask folders.")
18
+ parser.add_argument(
19
+ "--checkpoint",
20
+ default="detective_sam_v2",
21
+ help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota, detective_sam_v2.",
22
+ )
23
+ parser.add_argument("--dataset-root", default=str(repo_root / "demo" / "cocoglide"))
24
+ parser.add_argument("--output-dir", default=str(repo_root / "outputs" / "eval_demo"))
25
+ parser.add_argument("--device", default=None)
26
+ parser.add_argument("--threshold", type=float, default=0.5)
27
+ parser.add_argument("--max-samples", type=int, default=None)
28
+ parser.add_argument("--num-visualizations", type=int, default=4)
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ runner = DetectiveSAMRunner(checkpoint_path=args.checkpoint, device=args.device)
35
+ dataset = PairDataset(
36
+ root_dir=args.dataset_root,
37
+ img_size=runner.config.img_size,
38
+ perturbation_type=runner.config.perturbation_type,
39
+ perturbation_intensity=runner.config.perturbation_intensity,
40
+ max_samples=args.max_samples,
41
+ )
42
+
43
+ output_dir = Path(args.output_dir)
44
+ vis_dir = output_dir / "visualizations"
45
+ output_dir.mkdir(parents=True, exist_ok=True)
46
+ vis_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ per_sample_results: list[dict[str, float | str | None]] = []
49
+ for index, sample in enumerate(tqdm(dataset, desc="Evaluating")):
50
+ prediction = runner.predict_sample(sample, threshold=args.threshold)
51
+ gt_mask = sample.mask.squeeze().numpy().astype("uint8") if sample.mask is not None else None
52
+ per_sample_results.append(
53
+ {
54
+ "name": sample.name,
55
+ "iou": compute_iou(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
56
+ "f1": compute_f1(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
57
+ }
58
+ )
59
+
60
+ if index < args.num_visualizations:
61
+ save_prediction_outputs(
62
+ output_dir=vis_dir,
63
+ name=sample.name,
64
+ source_image=sample.source_image,
65
+ target_image=sample.target_image,
66
+ probability_map=prediction.probability,
67
+ pred_mask=prediction.pred_mask,
68
+ gt_mask=gt_mask,
69
+ )
70
+
71
+ payload = {
72
+ "checkpoint": str(runner.checkpoint_path.resolve()),
73
+ "dataset_root": str(Path(args.dataset_root).resolve()),
74
+ "threshold": args.threshold,
75
+ "summary": summarize_results(per_sample_results),
76
+ "samples": per_sample_results,
77
+ }
78
+ with (output_dir / "summary.json").open("w", encoding="utf-8") as handle:
79
+ json.dump(payload, handle, indent=2)
80
+
81
+ print(json.dumps(payload["summary"], indent=2))
82
+ print(f"Detailed results written to {output_dir / 'summary.json'}")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()
detectivesam_inference/metrics.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def compute_iou(pred_mask: np.ndarray, true_mask: np.ndarray) -> float:
7
+ pred = pred_mask.astype(bool)
8
+ true = true_mask.astype(bool)
9
+ intersection = np.logical_and(pred, true).sum()
10
+ union = np.logical_or(pred, true).sum()
11
+ if union == 0:
12
+ return 1.0
13
+ return float(intersection / union)
14
+
15
+
16
+ def compute_f1(pred_mask: np.ndarray, true_mask: np.ndarray) -> float:
17
+ pred = pred_mask.astype(bool)
18
+ true = true_mask.astype(bool)
19
+ if not pred.any() and not true.any():
20
+ return 1.0
21
+ if not pred.any() or not true.any():
22
+ return 0.0
23
+
24
+ true_positive = np.logical_and(pred, true).sum()
25
+ false_positive = np.logical_and(pred, np.logical_not(true)).sum()
26
+ false_negative = np.logical_and(np.logical_not(pred), true).sum()
27
+
28
+ precision = true_positive / (true_positive + false_positive) if (true_positive + false_positive) else 0.0
29
+ recall = true_positive / (true_positive + false_negative) if (true_positive + false_negative) else 0.0
30
+ if precision + recall == 0:
31
+ return 0.0
32
+ return float(2 * precision * recall / (precision + recall))
33
+
34
+
35
+ def summarize_results(results: list[dict[str, float | str | None]]) -> dict[str, int | float | None]:
36
+ if not results:
37
+ return {
38
+ "num_samples": 0,
39
+ "num_samples_with_gt": 0,
40
+ "mean_iou": None,
41
+ "mean_f1": None,
42
+ }
43
+
44
+ with_ground_truth = [item for item in results if item.get("iou") is not None]
45
+ return {
46
+ "num_samples": len(results),
47
+ "num_samples_with_gt": len(with_ground_truth),
48
+ "mean_iou": float(np.mean([item["iou"] for item in with_ground_truth])) if with_ground_truth else None,
49
+ "mean_f1": float(np.mean([item["f1"] for item in with_ground_truth])) if with_ground_truth else None,
50
+ }
detectivesam_inference/models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from detectivesam_inference.models.forgerylocalizer import ForgeryLocalizer
2
+
3
+ __all__ = ["ForgeryLocalizer"]
detectivesam_inference/models/adapters.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from detectivesam_inference.models.adapters_v1 import (
2
+ CoarseProcessingBlock,
3
+ FeatureFusionBlockSpatial,
4
+ FeaturePyramid,
5
+ FineProcessingBlock,
6
+ MaskAdapter,
7
+ RefineBlock,
8
+ SharedAdapter,
9
+ StreamPyramid,
10
+ )
11
+ from detectivesam_inference.models.adapters_v2 import (
12
+ CrossAttentionSharedAdapter,
13
+ FlexibleEvidenceBlock,
14
+ FlexibleEvidenceProjector,
15
+ SpatialCrossAttentionSharedAdapter,
16
+ StreamEvidenceBuilder,
17
+ Transformer2DBlock,
18
+ TransformerEvidenceMaskAdapter,
19
+ )
20
+
21
+ __all__ = [
22
+ "CoarseProcessingBlock",
23
+ "CrossAttentionSharedAdapter",
24
+ "FeatureFusionBlockSpatial",
25
+ "FeaturePyramid",
26
+ "FineProcessingBlock",
27
+ "FlexibleEvidenceBlock",
28
+ "FlexibleEvidenceProjector",
29
+ "MaskAdapter",
30
+ "RefineBlock",
31
+ "SharedAdapter",
32
+ "SpatialCrossAttentionSharedAdapter",
33
+ "StreamEvidenceBuilder",
34
+ "StreamPyramid",
35
+ "Transformer2DBlock",
36
+ "TransformerEvidenceMaskAdapter",
37
+ ]
detectivesam_inference/models/adapters_v1.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+
8
+ FeaturePyramid = list[torch.Tensor]
9
+ StreamPyramid = list[list[torch.Tensor]]
10
+
11
+
12
+ class SharedAdapter(nn.Module):
13
+ """Applies a residual adapter to each feature scale."""
14
+
15
+ def __init__(
16
+ self,
17
+ in_channels_list: list[int],
18
+ hidden_dim: int,
19
+ dropout_rate: float = 0.1,
20
+ max_streams: int = 2,
21
+ ) -> None:
22
+ super().__init__()
23
+ max_streams = max(max_streams, 1)
24
+
25
+ self.mlps_tune = nn.ModuleList(
26
+ nn.Conv2d(max_streams * channels, hidden_dim, kernel_size=1)
27
+ for channels in in_channels_list
28
+ )
29
+ self.mlps_bottleneck = nn.ModuleList(
30
+ nn.Sequential(nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1), nn.GELU())
31
+ for _ in in_channels_list
32
+ )
33
+ self.mlp_up = nn.ModuleList(
34
+ nn.Conv2d(hidden_dim, channels, kernel_size=1)
35
+ for channels in in_channels_list
36
+ )
37
+ self.activation = nn.GELU()
38
+ self.dropout = nn.Dropout2d(p=dropout_rate)
39
+
40
+ def forward(
41
+ self,
42
+ stream_features: list[torch.Tensor],
43
+ unadapted: torch.Tensor,
44
+ scale_idx: int,
45
+ ) -> torch.Tensor:
46
+ fused_streams = torch.cat(stream_features, dim=1) if stream_features else unadapted
47
+ hidden = self.mlps_tune[scale_idx](fused_streams)
48
+ hidden = self.activation(hidden)
49
+ hidden = self.dropout(hidden)
50
+ hidden = self.mlps_bottleneck[scale_idx](hidden)
51
+ delta = self.mlp_up[scale_idx](hidden)
52
+ return unadapted + delta
53
+
54
+
55
+
56
+
57
+ class RefineBlock(nn.Module):
58
+ """Refines the coarse mask with low-level features."""
59
+
60
+ def __init__(
61
+ self,
62
+ hidden_dim: int,
63
+ low_channels: int,
64
+ out_channels: int = 1,
65
+ dropout_rate: float = 0.0,
66
+ ) -> None:
67
+ super().__init__()
68
+ self.conv1 = nn.Conv2d(hidden_dim + low_channels, hidden_dim, kernel_size=3, padding=1)
69
+ self.activation1 = nn.GELU()
70
+ self.dropout1 = nn.Dropout2d(p=dropout_rate)
71
+ self.conv2 = nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1)
72
+ self.activation2 = nn.GELU()
73
+ self.dropout2 = nn.Dropout2d(p=dropout_rate)
74
+ self.conv3 = nn.Conv2d(hidden_dim, out_channels, kernel_size=1)
75
+
76
+ def forward(
77
+ self,
78
+ attention_features: torch.Tensor,
79
+ low_features: torch.Tensor,
80
+ coarse_upsampled: torch.Tensor,
81
+ ) -> torch.Tensor:
82
+ refined = torch.cat([attention_features, low_features], dim=1)
83
+ refined = self.conv1(refined)
84
+ refined = self.activation1(refined)
85
+ refined = self.dropout1(refined)
86
+ refined = self.conv2(refined)
87
+ refined = self.activation2(refined)
88
+ refined = self.dropout2(refined)
89
+ delta = self.conv3(refined)
90
+ return coarse_upsampled + delta
91
+
92
+
93
+ class CoarseProcessingBlock(nn.Module):
94
+ """Adds transformer-based coarse reasoning before refinement."""
95
+
96
+ def __init__(
97
+ self,
98
+ hidden_dim: int,
99
+ attn_dim: int,
100
+ n_heads: int,
101
+ num_encoder_layers: int,
102
+ dropout_rate: float,
103
+ downscale: int,
104
+ ) -> None:
105
+ super().__init__()
106
+ self.hidden_dim = hidden_dim
107
+ self.coarse_down = nn.Sequential(
108
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=downscale, stride=downscale, groups=hidden_dim),
109
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1),
110
+ nn.GELU(),
111
+ nn.Dropout2d(p=dropout_rate),
112
+ )
113
+ self.pos_embed_conv = nn.Conv2d(2, hidden_dim, kernel_size=1)
114
+ self.pos_dropout = nn.Dropout2d(p=dropout_rate)
115
+ self.feat_proj = nn.Sequential(
116
+ nn.Linear(hidden_dim, attn_dim),
117
+ nn.Dropout(p=dropout_rate),
118
+ )
119
+ encoder_layer = nn.TransformerEncoderLayer(
120
+ d_model=attn_dim,
121
+ nhead=n_heads,
122
+ dim_feedforward=attn_dim * 4,
123
+ dropout=dropout_rate,
124
+ activation="gelu",
125
+ batch_first=True,
126
+ )
127
+ self.transformer_encoder = nn.TransformerEncoder(
128
+ encoder_layer,
129
+ num_layers=num_encoder_layers,
130
+ )
131
+ self.transformer_out = nn.Sequential(
132
+ nn.Linear(attn_dim, hidden_dim),
133
+ nn.Dropout(p=dropout_rate),
134
+ )
135
+ self.residual_gate_conv = nn.Sequential(
136
+ nn.Conv2d(hidden_dim * 2, hidden_dim // 4, kernel_size=3, padding=1),
137
+ nn.GELU(),
138
+ nn.Dropout2d(p=dropout_rate),
139
+ nn.Conv2d(hidden_dim // 4, 1, kernel_size=1),
140
+ )
141
+ self.cached_pos_encodings: dict[tuple[int, int], torch.Tensor] = {}
142
+
143
+ def _generate_pos_encoding(self, height: int, width: int) -> torch.Tensor:
144
+ device = self.pos_embed_conv.weight.device
145
+ y_pos = torch.linspace(-1, 1, height, device=device).view(height, 1).expand(height, width)
146
+ x_pos = torch.linspace(-1, 1, width, device=device).view(1, width).expand(height, width)
147
+ pos_grid = torch.stack([y_pos, x_pos], dim=0).unsqueeze(0)
148
+ return self.pos_embed_conv(pos_grid)
149
+
150
+ def _get_positional_encoding(self, batch_size: int, height: int, width: int) -> torch.Tensor:
151
+ key = (height, width)
152
+ device = self.pos_embed_conv.weight.device
153
+ if key not in self.cached_pos_encodings:
154
+ self.cached_pos_encodings[key] = self._generate_pos_encoding(height, width).detach()
155
+
156
+ cached_encoding = self.cached_pos_encodings[key]
157
+ if cached_encoding.device != device:
158
+ cached_encoding = cached_encoding.to(device)
159
+ self.cached_pos_encodings[key] = cached_encoding
160
+ return cached_encoding.expand(batch_size, -1, -1, -1)
161
+
162
+ def forward(self, fused: torch.Tensor) -> torch.Tensor:
163
+ coarse_features = self.coarse_down(fused)
164
+ batch_size, _, height, width = coarse_features.shape
165
+
166
+ pos_embed = self._get_positional_encoding(batch_size, height, width)
167
+ pos_embed = self.pos_dropout(pos_embed)
168
+ coarse_with_position = coarse_features + pos_embed
169
+
170
+ feature_sequence = coarse_with_position.flatten(2).permute(0, 2, 1)
171
+ feature_sequence = self.feat_proj(feature_sequence)
172
+ transformer_output = self.transformer_encoder(feature_sequence)
173
+ hidden = self.transformer_out(transformer_output)
174
+ hidden = hidden.permute(0, 2, 1).view(batch_size, self.hidden_dim, height, width)
175
+
176
+ gate_input = torch.cat([hidden, coarse_features], dim=1)
177
+ residual_gate = torch.sigmoid(self.residual_gate_conv(gate_input))
178
+ return residual_gate * hidden + (1 - residual_gate) * coarse_features
179
+
180
+
181
+ class FineProcessingBlock(nn.Module):
182
+ """Produces the coarse mask and uncertainty map."""
183
+
184
+ def __init__(self, hidden_dim: int, dropout_rate: float) -> None:
185
+ super().__init__()
186
+ self.feature_refinement = nn.Sequential(
187
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
188
+ nn.GELU(),
189
+ nn.Dropout2d(p=dropout_rate),
190
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
191
+ nn.GELU(),
192
+ nn.Dropout2d(p=dropout_rate),
193
+ )
194
+ self.coarse_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
195
+ self.uncertainty_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
196
+
197
+ def forward(
198
+ self,
199
+ hidden: torch.Tensor,
200
+ output_size: tuple[int, int],
201
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
202
+ hidden = self.feature_refinement(hidden)
203
+ coarse_logit = self.coarse_head(hidden)
204
+ uncertainty_logit = self.uncertainty_head(hidden)
205
+
206
+ coarse_mask = F.interpolate(
207
+ coarse_logit,
208
+ size=output_size,
209
+ mode="bilinear",
210
+ align_corners=False,
211
+ )
212
+ uncertainty_map = F.interpolate(
213
+ uncertainty_logit,
214
+ size=output_size,
215
+ mode="bilinear",
216
+ align_corners=False,
217
+ )
218
+ return hidden, coarse_mask, torch.sigmoid(uncertainty_map)
219
+
220
+
221
+ class FeatureFusionBlockSpatial(nn.Module):
222
+ """Fuses original, adapted, and perturbed features with per-pixel attention."""
223
+
224
+ def __init__(
225
+ self,
226
+ in_channels_list: list[int],
227
+ hidden_dim: int = 128,
228
+ dropout_rate: float = 0.1,
229
+ max_streams: int = 2,
230
+ attn_reduction: int = 4,
231
+ ) -> None:
232
+ super().__init__()
233
+ self.num_streams = 2 + max_streams
234
+ self.att_conv = nn.ModuleList()
235
+ self.proj_conv = nn.ModuleList()
236
+
237
+ for channels in in_channels_list:
238
+ total_channels = channels * self.num_streams
239
+ mid_channels = max(total_channels // attn_reduction, 8)
240
+ self.att_conv.append(
241
+ nn.Sequential(
242
+ nn.Conv2d(
243
+ total_channels,
244
+ mid_channels,
245
+ kernel_size=3,
246
+ padding=1,
247
+ groups=self.num_streams,
248
+ bias=False,
249
+ ),
250
+ nn.GELU(),
251
+ nn.Conv2d(mid_channels, self.num_streams, kernel_size=1, bias=False),
252
+ )
253
+ )
254
+ self.proj_conv.append(
255
+ nn.Sequential(
256
+ nn.Conv2d(channels, hidden_dim, kernel_size=1),
257
+ nn.GELU(),
258
+ nn.Dropout2d(p=dropout_rate),
259
+ )
260
+ )
261
+
262
+ fusion_channels = hidden_dim * len(in_channels_list)
263
+ self.fuse_project = nn.Sequential(
264
+ nn.Conv2d(fusion_channels, hidden_dim, kernel_size=1),
265
+ nn.GELU(),
266
+ nn.Dropout2d(p=dropout_rate),
267
+ )
268
+
269
+ def forward(
270
+ self,
271
+ adapted: FeaturePyramid,
272
+ unadapted: FeaturePyramid,
273
+ streams_unadapted: StreamPyramid,
274
+ output_size: tuple[int, int],
275
+ ) -> torch.Tensor:
276
+ fused_scales = []
277
+ for scale_idx, (att_head, projection) in enumerate(zip(self.att_conv, self.proj_conv)):
278
+ streams = [adapted[scale_idx], unadapted[scale_idx], *streams_unadapted[scale_idx]]
279
+ logits = att_head(torch.cat(streams, dim=1))
280
+ weights = F.softmax(logits, dim=1).unsqueeze(2)
281
+ fused = (torch.stack(streams, dim=1) * weights).sum(dim=1)
282
+ fused = projection(fused)
283
+ fused = F.interpolate(
284
+ fused,
285
+ size=output_size,
286
+ mode="bilinear",
287
+ align_corners=False,
288
+ )
289
+ fused_scales.append(fused)
290
+
291
+ return self.fuse_project(torch.cat(fused_scales, dim=1))
292
+
293
+
294
+
295
+
296
+ class MaskAdapter(nn.Module):
297
+ """Builds the prompt mask passed into the SAM decoder."""
298
+
299
+ def __init__(
300
+ self,
301
+ hidden_dim: int = 256,
302
+ downscale: int = 16,
303
+ output_resolution: tuple[int, int] = (128, 128),
304
+ in_channels_list: list[int] | None = None,
305
+ attn_dim: int = 16,
306
+ n_heads: int = 4,
307
+ num_encoder_layers: int = 2,
308
+ dropout_rate: float = 0.1,
309
+ max_streams: int = 2,
310
+ ) -> None:
311
+ super().__init__()
312
+ channels = in_channels_list or [256, 32, 64]
313
+ self.downscale = downscale
314
+ self.output_resolution = output_resolution
315
+
316
+ self.feature_fusion = FeatureFusionBlockSpatial(
317
+ in_channels_list=channels,
318
+ hidden_dim=hidden_dim,
319
+ dropout_rate=dropout_rate,
320
+ max_streams=max_streams,
321
+ )
322
+ self.coarse_processor = CoarseProcessingBlock(
323
+ hidden_dim=hidden_dim,
324
+ attn_dim=attn_dim,
325
+ n_heads=n_heads,
326
+ num_encoder_layers=num_encoder_layers,
327
+ dropout_rate=dropout_rate,
328
+ downscale=downscale,
329
+ )
330
+ self.fine_processor = FineProcessingBlock(hidden_dim, dropout_rate)
331
+ self.spatial_gate = nn.Sequential(
332
+ nn.Conv2d(2, hidden_dim // 2, kernel_size=3, padding=1),
333
+ nn.GELU(),
334
+ nn.Dropout2d(p=dropout_rate),
335
+ nn.Conv2d(hidden_dim // 2, 1, kernel_size=1),
336
+ nn.Sigmoid(),
337
+ )
338
+ self.refine_head = RefineBlock(
339
+ hidden_dim=hidden_dim,
340
+ low_channels=32,
341
+ out_channels=1,
342
+ dropout_rate=dropout_rate,
343
+ )
344
+
345
+ def forward(
346
+ self,
347
+ adapted: FeaturePyramid,
348
+ streams_unadapted: StreamPyramid,
349
+ unadapted: FeaturePyramid,
350
+ ) -> torch.Tensor:
351
+ output_height, output_width = self.output_resolution
352
+ output_size = (output_height, output_width)
353
+ coarse_size = (output_height // self.downscale, output_width // self.downscale)
354
+
355
+ fused = self.feature_fusion(adapted, unadapted, streams_unadapted, output_size)
356
+ hidden = self.coarse_processor(fused)
357
+ if hidden.shape[-2:] != coarse_size:
358
+ hidden = F.adaptive_avg_pool2d(hidden, coarse_size)
359
+
360
+ hidden, coarse_mask, uncertainty_map = self.fine_processor(hidden, output_size)
361
+ attention_features = F.interpolate(hidden, size=output_size, mode="bilinear", align_corners=False)
362
+ low_features = F.interpolate(unadapted[1], size=output_size, mode="bilinear", align_corners=False)
363
+ refined_mask = self.refine_head(attention_features, low_features, coarse_mask)
364
+
365
+ spatial_gate = self.spatial_gate(torch.cat([coarse_mask, uncertainty_map], dim=1))
366
+ return spatial_gate * refined_mask + (1 - spatial_gate) * coarse_mask
detectivesam_inference/models/adapters_v2.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+ from typing import Optional
7
+
8
+
9
+ FeaturePyramid = list[torch.Tensor]
10
+ StreamPyramid = list[list[torch.Tensor]]
11
+
12
+
13
+ def _group_count(channels: int, max_groups: int = 8) -> int:
14
+ for groups in range(min(max_groups, channels), 0, -1):
15
+ if channels % groups == 0:
16
+ return groups
17
+ return 1
18
+
19
+
20
+ class FlexibleEvidenceBlock(nn.Module):
21
+ def __init__(self, channels: int, dropout_rate: float = 0.1, expansion: int = 2) -> None:
22
+ super().__init__()
23
+ expanded = channels * expansion
24
+ self.norm = nn.GroupNorm(_group_count(channels), channels)
25
+ self.expand = nn.Conv2d(channels, expanded, kernel_size=1)
26
+ self.depthwise = nn.Conv2d(expanded, expanded, kernel_size=3, padding=1, groups=expanded)
27
+ self.act = nn.GELU()
28
+ self.channel_gate = nn.Sequential(
29
+ nn.AdaptiveAvgPool2d(1),
30
+ nn.Conv2d(expanded, max(channels // 4, 8), kernel_size=1),
31
+ nn.GELU(),
32
+ nn.Conv2d(max(channels // 4, 8), expanded, kernel_size=1),
33
+ nn.Sigmoid(),
34
+ )
35
+ self.project = nn.Conv2d(expanded, channels, kernel_size=1)
36
+ self.dropout = nn.Dropout2d(p=dropout_rate)
37
+
38
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
39
+ residual = x
40
+ hidden = self.norm(x)
41
+ hidden = self.expand(hidden)
42
+ hidden = self.depthwise(hidden)
43
+ hidden = self.act(hidden)
44
+ hidden = hidden * self.channel_gate(hidden)
45
+ hidden = self.project(hidden)
46
+ hidden = self.dropout(hidden)
47
+ return residual + hidden
48
+
49
+
50
+ class FlexibleEvidenceProjector(nn.Module):
51
+ """Channel and local-spatial evidence projector used by v2 adapters."""
52
+
53
+ def __init__(
54
+ self,
55
+ in_channels: int,
56
+ hidden_dim: int,
57
+ dropout_rate: float = 0.1,
58
+ depth: int = 2,
59
+ ) -> None:
60
+ super().__init__()
61
+ self.input_proj = nn.Sequential(
62
+ nn.Conv2d(in_channels, hidden_dim, kernel_size=1),
63
+ nn.GroupNorm(_group_count(hidden_dim), hidden_dim),
64
+ nn.GELU(),
65
+ nn.Dropout2d(p=dropout_rate),
66
+ )
67
+ self.blocks = nn.Sequential(
68
+ *[
69
+ FlexibleEvidenceBlock(hidden_dim, dropout_rate=dropout_rate)
70
+ for _ in range(depth)
71
+ ]
72
+ )
73
+
74
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
75
+ return self.blocks(self.input_proj(x))
76
+
77
+
78
+ class StreamEvidenceBuilder(nn.Module):
79
+ """Build target evidence from raw target and perturbation-stream features."""
80
+
81
+ def __init__(
82
+ self,
83
+ in_channels_list: list[int],
84
+ hidden_dim: int,
85
+ dropout_rate: float = 0.1,
86
+ max_streams: int = 3,
87
+ ) -> None:
88
+ super().__init__()
89
+ self.max_streams = max(1, max_streams)
90
+ self.projections = nn.ModuleList(
91
+ [
92
+ FlexibleEvidenceProjector(
93
+ in_channels=channels * (1 + 3 * self.max_streams),
94
+ hidden_dim=hidden_dim,
95
+ dropout_rate=dropout_rate,
96
+ depth=3,
97
+ )
98
+ for channels in in_channels_list
99
+ ]
100
+ )
101
+
102
+ def forward(
103
+ self,
104
+ unadapted: FeaturePyramid,
105
+ streams_unadapted: StreamPyramid,
106
+ ) -> FeaturePyramid:
107
+ evidence = []
108
+ for scale_idx, (target, streams) in enumerate(zip(unadapted, streams_unadapted)):
109
+ fixed_streams = list(streams[: self.max_streams])
110
+ while len(fixed_streams) < self.max_streams:
111
+ fixed_streams.append(torch.zeros_like(target))
112
+
113
+ abs_diffs = [(target - stream).abs() for stream in fixed_streams]
114
+ signed_diffs = [target - stream for stream in fixed_streams]
115
+ raw = torch.cat([target, *fixed_streams, *abs_diffs, *signed_diffs], dim=1)
116
+ evidence.append(self.projections[scale_idx](raw))
117
+ return evidence
118
+
119
+
120
+ class CrossAttentionSharedAdapter(nn.Module):
121
+ """Per-scale cross-attention adapter over perturbation streams."""
122
+
123
+ def __init__(
124
+ self,
125
+ in_channels_list: list[int],
126
+ hidden_dim: int,
127
+ dropout_rate: float = 0.1,
128
+ max_streams: int = 2,
129
+ num_heads: int = 4,
130
+ ) -> None:
131
+ super().__init__()
132
+ self.num_scales = len(in_channels_list)
133
+ self.max_streams = max(1, max_streams)
134
+ self.num_heads = max(1, num_heads)
135
+ if hidden_dim % self.num_heads != 0:
136
+ raise ValueError(f"hidden_dim ({hidden_dim}) must be divisible by num_heads ({self.num_heads})")
137
+
138
+ self.head_dim = hidden_dim // self.num_heads
139
+ self.scale = self.head_dim**-0.5
140
+ self.q_proj = nn.ModuleList(nn.Conv2d(channels, hidden_dim, kernel_size=1) for channels in in_channels_list)
141
+ self.k_proj = nn.ModuleList(nn.Conv2d(channels, hidden_dim, kernel_size=1) for channels in in_channels_list)
142
+ self.v_proj = nn.ModuleList(nn.Conv2d(channels, hidden_dim, kernel_size=1) for channels in in_channels_list)
143
+ self.out_proj = nn.ModuleList(
144
+ [
145
+ nn.Sequential(
146
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1),
147
+ nn.GELU(),
148
+ nn.Dropout2d(p=dropout_rate),
149
+ nn.Conv2d(hidden_dim, channels, kernel_size=1),
150
+ )
151
+ for channels in in_channels_list
152
+ ]
153
+ )
154
+ self.evidence_q_proj = nn.ModuleList(
155
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1)
156
+ for _ in in_channels_list
157
+ )
158
+ self.evidence_context_proj = nn.ModuleList(
159
+ [
160
+ nn.Sequential(
161
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1),
162
+ nn.GELU(),
163
+ nn.Dropout2d(p=dropout_rate),
164
+ )
165
+ for _ in in_channels_list
166
+ ]
167
+ )
168
+ self.residual_gate = nn.ModuleList(
169
+ [
170
+ nn.Sequential(
171
+ nn.Conv2d(channels + hidden_dim, channels, kernel_size=1),
172
+ nn.Sigmoid(),
173
+ )
174
+ for channels in in_channels_list
175
+ ]
176
+ )
177
+ self.dropout = nn.Dropout2d(p=dropout_rate)
178
+
179
+ def forward(
180
+ self,
181
+ stream_features: list[torch.Tensor],
182
+ unadapted: torch.Tensor,
183
+ scale_idx: int,
184
+ return_delta: bool = False,
185
+ evidence: Optional[torch.Tensor] = None,
186
+ ) -> torch.Tensor:
187
+ if not stream_features:
188
+ delta = torch.zeros_like(unadapted)
189
+ adapted = unadapted
190
+ if return_delta:
191
+ return adapted, delta
192
+ return adapted
193
+
194
+ batch_size, _, height, width = unadapted.shape
195
+ stream_count = len(stream_features)
196
+ query = self.q_proj[scale_idx](unadapted)
197
+ if evidence is not None:
198
+ if evidence.shape[-2:] != query.shape[-2:]:
199
+ evidence = F.interpolate(evidence, size=query.shape[-2:], mode="bilinear", align_corners=False)
200
+ query = query + self.evidence_q_proj[scale_idx](evidence)
201
+ query = query.view(batch_size, self.num_heads, self.head_dim, 1, height, width)
202
+
203
+ keys = [self.k_proj[scale_idx](feature) for feature in stream_features]
204
+ values = [self.v_proj[scale_idx](feature) for feature in stream_features]
205
+ key = torch.stack(keys, dim=1).view(batch_size, stream_count, self.num_heads, self.head_dim, height, width)
206
+ value = torch.stack(values, dim=1).view(batch_size, stream_count, self.num_heads, self.head_dim, height, width)
207
+ key = key.permute(0, 2, 3, 1, 4, 5)
208
+ value = value.permute(0, 2, 3, 1, 4, 5)
209
+
210
+ attention_logits = (query * key).sum(dim=2) * self.scale
211
+ attention = F.softmax(attention_logits, dim=2)
212
+ context = (attention.unsqueeze(2) * value).sum(dim=3)
213
+ context = context.reshape(batch_size, self.num_heads * self.head_dim, height, width)
214
+ if evidence is not None:
215
+ context = context + self.evidence_context_proj[scale_idx](evidence)
216
+ context = self.dropout(context)
217
+
218
+ raw_delta = self.out_proj[scale_idx](context)
219
+ gate = self.residual_gate[scale_idx](torch.cat([unadapted, context], dim=1))
220
+ delta = gate * raw_delta
221
+ adapted = unadapted + delta
222
+ if return_delta:
223
+ return adapted, delta
224
+ return adapted
225
+
226
+
227
+ class SpatialCrossAttentionSharedAdapter(CrossAttentionSharedAdapter):
228
+ """Cross-attention adapter with global low-res and windowed mid-res spatial mixing."""
229
+
230
+ def __init__(
231
+ self,
232
+ in_channels_list: list[int],
233
+ hidden_dim: int,
234
+ dropout_rate: float = 0.1,
235
+ max_streams: int = 2,
236
+ num_heads: int = 4,
237
+ global_spatial_scales: tuple[int, ...] = (0,),
238
+ windowed_spatial_scales: tuple[int, ...] = (2,),
239
+ window_size: int = 8,
240
+ ) -> None:
241
+ super().__init__(
242
+ in_channels_list=in_channels_list,
243
+ hidden_dim=hidden_dim,
244
+ dropout_rate=dropout_rate,
245
+ max_streams=max_streams,
246
+ num_heads=num_heads,
247
+ )
248
+ self.global_spatial_scales = set(global_spatial_scales)
249
+ self.windowed_spatial_scales = set(windowed_spatial_scales)
250
+ self.window_size = window_size
251
+
252
+ def _apply_residual(
253
+ self,
254
+ context: torch.Tensor,
255
+ unadapted: torch.Tensor,
256
+ scale_idx: int,
257
+ return_delta: bool,
258
+ evidence: Optional[torch.Tensor] = None,
259
+ ) -> torch.Tensor:
260
+ if evidence is not None:
261
+ if evidence.shape[-2:] != context.shape[-2:]:
262
+ evidence = F.interpolate(evidence, size=context.shape[-2:], mode="bilinear", align_corners=False)
263
+ context = context + self.evidence_context_proj[scale_idx](evidence)
264
+ context = self.dropout(context)
265
+ raw_delta = self.out_proj[scale_idx](context)
266
+ gate = self.residual_gate[scale_idx](torch.cat([unadapted, context], dim=1))
267
+ delta = gate * raw_delta
268
+ adapted = unadapted + delta
269
+ if return_delta:
270
+ return adapted, delta
271
+ return adapted
272
+
273
+ def _global_spatial_context(
274
+ self,
275
+ stream_features: list[torch.Tensor],
276
+ unadapted: torch.Tensor,
277
+ scale_idx: int,
278
+ evidence: Optional[torch.Tensor] = None,
279
+ ) -> torch.Tensor:
280
+ batch_size, _, height, width = unadapted.shape
281
+ stream_count = len(stream_features)
282
+ num_tokens = height * width
283
+ query = self.q_proj[scale_idx](unadapted)
284
+ if evidence is not None:
285
+ if evidence.shape[-2:] != query.shape[-2:]:
286
+ evidence = F.interpolate(evidence, size=query.shape[-2:], mode="bilinear", align_corners=False)
287
+ query = query + self.evidence_q_proj[scale_idx](evidence)
288
+ query = query.view(batch_size, self.num_heads, self.head_dim, num_tokens).transpose(2, 3)
289
+
290
+ keys = [self.k_proj[scale_idx](feature) for feature in stream_features]
291
+ values = [self.v_proj[scale_idx](feature) for feature in stream_features]
292
+ key = torch.stack(keys, dim=1).view(batch_size, stream_count, self.num_heads, self.head_dim, num_tokens)
293
+ value = torch.stack(values, dim=1).view(batch_size, stream_count, self.num_heads, self.head_dim, num_tokens)
294
+ key = key.permute(0, 2, 1, 4, 3).reshape(batch_size, self.num_heads, stream_count * num_tokens, self.head_dim)
295
+ value = value.permute(0, 2, 1, 4, 3).reshape(batch_size, self.num_heads, stream_count * num_tokens, self.head_dim)
296
+
297
+ attention = torch.matmul(query, key.transpose(-1, -2)) * self.scale
298
+ attention = F.softmax(attention, dim=-1)
299
+ context = torch.matmul(attention, value)
300
+ return context.transpose(2, 3).reshape(batch_size, self.num_heads * self.head_dim, height, width)
301
+
302
+ def _window_partition_q(self, x: torch.Tensor, window_size: int) -> torch.Tensor:
303
+ batch_size, heads, head_dim, height, width = x.shape
304
+ return (
305
+ x.permute(0, 3, 4, 1, 2)
306
+ .reshape(batch_size, height // window_size, window_size, width // window_size, window_size, heads, head_dim)
307
+ .permute(0, 1, 3, 5, 2, 4, 6)
308
+ .reshape(batch_size * (height // window_size) * (width // window_size), heads, window_size * window_size, head_dim)
309
+ )
310
+
311
+ def _window_partition_kv(self, x: torch.Tensor, window_size: int) -> torch.Tensor:
312
+ batch_size, streams, heads, head_dim, height, width = x.shape
313
+ return (
314
+ x.permute(0, 4, 5, 1, 2, 3)
315
+ .reshape(
316
+ batch_size,
317
+ height // window_size,
318
+ window_size,
319
+ width // window_size,
320
+ window_size,
321
+ streams,
322
+ heads,
323
+ head_dim,
324
+ )
325
+ .permute(0, 1, 3, 6, 2, 4, 5, 7)
326
+ .reshape(
327
+ batch_size * (height // window_size) * (width // window_size),
328
+ heads,
329
+ window_size * window_size * streams,
330
+ head_dim,
331
+ )
332
+ )
333
+
334
+ def _window_unpartition(
335
+ self,
336
+ x: torch.Tensor,
337
+ batch_size: int,
338
+ height: int,
339
+ width: int,
340
+ window_size: int,
341
+ ) -> torch.Tensor:
342
+ heads = x.shape[1]
343
+ head_dim = x.shape[-1]
344
+ windows_h = height // window_size
345
+ windows_w = width // window_size
346
+ return (
347
+ x.reshape(batch_size, windows_h, windows_w, heads, window_size, window_size, head_dim)
348
+ .permute(0, 3, 6, 1, 4, 2, 5)
349
+ .reshape(batch_size, heads, head_dim, height, width)
350
+ )
351
+
352
+ def _windowed_spatial_context(
353
+ self,
354
+ stream_features: list[torch.Tensor],
355
+ unadapted: torch.Tensor,
356
+ scale_idx: int,
357
+ evidence: Optional[torch.Tensor] = None,
358
+ ) -> torch.Tensor:
359
+ batch_size, _, height, width = unadapted.shape
360
+ stream_count = len(stream_features)
361
+ window_size = self.window_size
362
+ pad_h = (window_size - height % window_size) % window_size
363
+ pad_w = (window_size - width % window_size) % window_size
364
+ padded_h = height + pad_h
365
+ padded_w = width + pad_w
366
+
367
+ query = self.q_proj[scale_idx](unadapted)
368
+ if evidence is not None:
369
+ if evidence.shape[-2:] != query.shape[-2:]:
370
+ evidence = F.interpolate(evidence, size=query.shape[-2:], mode="bilinear", align_corners=False)
371
+ query = query + self.evidence_q_proj[scale_idx](evidence)
372
+ query = query.view(batch_size, self.num_heads, self.head_dim, height, width)
373
+ keys = [
374
+ self.k_proj[scale_idx](feature).view(batch_size, self.num_heads, self.head_dim, height, width)
375
+ for feature in stream_features
376
+ ]
377
+ values = [
378
+ self.v_proj[scale_idx](feature).view(batch_size, self.num_heads, self.head_dim, height, width)
379
+ for feature in stream_features
380
+ ]
381
+ if pad_h or pad_w:
382
+ query = F.pad(query, (0, pad_w, 0, pad_h))
383
+ keys = [F.pad(key, (0, pad_w, 0, pad_h)) for key in keys]
384
+ values = [F.pad(value, (0, pad_w, 0, pad_h)) for value in values]
385
+
386
+ key = torch.stack(keys, dim=1)
387
+ value = torch.stack(values, dim=1)
388
+ query_windows = self._window_partition_q(query, window_size)
389
+ key_windows = self._window_partition_kv(key, window_size)
390
+ value_windows = self._window_partition_kv(value, window_size)
391
+
392
+ attention = torch.matmul(query_windows, key_windows.transpose(-1, -2)) * self.scale
393
+ attention = F.softmax(attention, dim=-1)
394
+ context_windows = torch.matmul(attention, value_windows)
395
+ context = self._window_unpartition(context_windows, batch_size, padded_h, padded_w, window_size)
396
+ return context[:, :, :, :height, :width].reshape(batch_size, self.num_heads * self.head_dim, height, width)
397
+
398
+ def forward(
399
+ self,
400
+ stream_features: list[torch.Tensor],
401
+ unadapted: torch.Tensor,
402
+ scale_idx: int,
403
+ return_delta: bool = False,
404
+ evidence: Optional[torch.Tensor] = None,
405
+ ) -> torch.Tensor:
406
+ if not stream_features:
407
+ delta = torch.zeros_like(unadapted)
408
+ adapted = unadapted
409
+ if return_delta:
410
+ return adapted, delta
411
+ return adapted
412
+ if scale_idx in self.global_spatial_scales:
413
+ context = self._global_spatial_context(stream_features, unadapted, scale_idx, evidence)
414
+ return self._apply_residual(context, unadapted, scale_idx, return_delta, evidence)
415
+ if scale_idx in self.windowed_spatial_scales:
416
+ context = self._windowed_spatial_context(stream_features, unadapted, scale_idx, evidence)
417
+ return self._apply_residual(context, unadapted, scale_idx, return_delta, evidence)
418
+ return super().forward(stream_features, unadapted, scale_idx, return_delta, evidence)
419
+
420
+
421
+
422
+
423
+ class Transformer2DBlock(nn.Module):
424
+ def __init__(
425
+ self,
426
+ hidden_dim: int,
427
+ num_heads: int = 4,
428
+ dropout_rate: float = 0.1,
429
+ window_size: Optional[int] = None,
430
+ mlp_ratio: int = 2,
431
+ ) -> None:
432
+ super().__init__()
433
+ self.hidden_dim = hidden_dim
434
+ self.window_size = window_size
435
+ self.norm1 = nn.LayerNorm(hidden_dim)
436
+ self.attn = nn.MultiheadAttention(
437
+ hidden_dim,
438
+ num_heads=num_heads,
439
+ dropout=dropout_rate,
440
+ batch_first=True,
441
+ )
442
+ self.norm2 = nn.GroupNorm(_group_count(hidden_dim), hidden_dim)
443
+ expanded = hidden_dim * mlp_ratio
444
+ self.mlp = nn.Sequential(
445
+ nn.Conv2d(hidden_dim, expanded, kernel_size=1),
446
+ nn.GELU(),
447
+ nn.Conv2d(expanded, expanded, kernel_size=3, padding=1, groups=expanded),
448
+ nn.GELU(),
449
+ nn.Dropout2d(p=dropout_rate),
450
+ nn.Conv2d(expanded, hidden_dim, kernel_size=1),
451
+ nn.Dropout2d(p=dropout_rate),
452
+ )
453
+
454
+ def _global_attention(self, x: torch.Tensor) -> torch.Tensor:
455
+ batch_size, channels, height, width = x.shape
456
+ sequence = x.flatten(2).transpose(1, 2)
457
+ sequence_norm = self.norm1(sequence)
458
+ attention_out, _ = self.attn(sequence_norm, sequence_norm, sequence_norm, need_weights=False)
459
+ return attention_out.transpose(1, 2).reshape(batch_size, channels, height, width)
460
+
461
+ def _window_partition(self, x: torch.Tensor, window_size: int) -> torch.Tensor:
462
+ batch_size, channels, height, width = x.shape
463
+ return (
464
+ x.reshape(batch_size, channels, height // window_size, window_size, width // window_size, window_size)
465
+ .permute(0, 2, 4, 3, 5, 1)
466
+ .reshape(batch_size * (height // window_size) * (width // window_size), window_size * window_size, channels)
467
+ )
468
+
469
+ def _window_unpartition(
470
+ self,
471
+ windows: torch.Tensor,
472
+ batch_size: int,
473
+ height: int,
474
+ width: int,
475
+ window_size: int,
476
+ ) -> torch.Tensor:
477
+ channels = windows.shape[-1]
478
+ return (
479
+ windows.reshape(
480
+ batch_size,
481
+ height // window_size,
482
+ width // window_size,
483
+ window_size,
484
+ window_size,
485
+ channels,
486
+ )
487
+ .permute(0, 5, 1, 3, 2, 4)
488
+ .reshape(batch_size, channels, height, width)
489
+ )
490
+
491
+ def _window_attention(self, x: torch.Tensor) -> torch.Tensor:
492
+ batch_size, _, height, width = x.shape
493
+ window_size = self.window_size
494
+ pad_h = (window_size - height % window_size) % window_size
495
+ pad_w = (window_size - width % window_size) % window_size
496
+ x_padded = F.pad(x, (0, pad_w, 0, pad_h)) if pad_h or pad_w else x
497
+ _, _, padded_h, padded_w = x_padded.shape
498
+ windows = self._window_partition(x_padded, window_size)
499
+ windows_norm = self.norm1(windows)
500
+ attention_windows, _ = self.attn(windows_norm, windows_norm, windows_norm, need_weights=False)
501
+ out = self._window_unpartition(attention_windows, batch_size, padded_h, padded_w, window_size)
502
+ return out[:, :, :height, :width]
503
+
504
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
505
+ if self.window_size is None:
506
+ x = x + self._global_attention(x)
507
+ else:
508
+ x = x + self._window_attention(x)
509
+ x = x + self.mlp(self.norm2(x))
510
+ return x
511
+
512
+
513
+ class TransformerEvidenceMaskAdapter(nn.Module):
514
+ """V2 mask adapter with evidence projection and efficient 2D transformer sharing."""
515
+
516
+ def __init__(
517
+ self,
518
+ hidden_dim: int = 128,
519
+ out_channels: int = 1,
520
+ output_resolution: tuple[int, int] = (128, 128),
521
+ in_channels_list: list[int] | None = None,
522
+ dropout_rate: float = 0.1,
523
+ num_heads: int = 4,
524
+ window_size: int = 8,
525
+ ) -> None:
526
+ super().__init__()
527
+ channels = in_channels_list or [256, 32, 64]
528
+ self.output_resolution = output_resolution
529
+ self.hidden_dim = hidden_dim
530
+ self.mask_evidence_proj = nn.ModuleList(
531
+ [
532
+ FlexibleEvidenceProjector(
533
+ in_channels=hidden_dim + (2 * in_channels),
534
+ hidden_dim=hidden_dim,
535
+ dropout_rate=dropout_rate,
536
+ depth=2,
537
+ )
538
+ for in_channels in channels
539
+ ]
540
+ )
541
+ self.low_blocks = nn.Sequential(
542
+ *[
543
+ Transformer2DBlock(hidden_dim, num_heads=num_heads, dropout_rate=dropout_rate, window_size=None)
544
+ for _ in range(2)
545
+ ]
546
+ )
547
+ self.mid_fuse = FlexibleEvidenceProjector(
548
+ in_channels=hidden_dim * 2,
549
+ hidden_dim=hidden_dim,
550
+ dropout_rate=dropout_rate,
551
+ depth=1,
552
+ )
553
+ self.mid_blocks = nn.Sequential(
554
+ *[
555
+ Transformer2DBlock(hidden_dim, num_heads=num_heads, dropout_rate=dropout_rate, window_size=window_size)
556
+ for _ in range(2)
557
+ ]
558
+ )
559
+ self.high_fuse = FlexibleEvidenceProjector(
560
+ in_channels=hidden_dim * 2,
561
+ hidden_dim=hidden_dim,
562
+ dropout_rate=dropout_rate,
563
+ depth=1,
564
+ )
565
+ self.high_blocks = nn.Sequential(
566
+ *[
567
+ Transformer2DBlock(hidden_dim, num_heads=num_heads, dropout_rate=dropout_rate, window_size=window_size)
568
+ for _ in range(2)
569
+ ]
570
+ )
571
+ self.mask_head = nn.Sequential(
572
+ nn.GroupNorm(_group_count(hidden_dim), hidden_dim),
573
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
574
+ nn.GELU(),
575
+ nn.Dropout2d(p=dropout_rate),
576
+ nn.Conv2d(hidden_dim, out_channels, kernel_size=1),
577
+ )
578
+ self.aux_head = nn.Sequential(
579
+ nn.GroupNorm(_group_count(hidden_dim), hidden_dim),
580
+ nn.Conv2d(hidden_dim, out_channels, kernel_size=1),
581
+ )
582
+
583
+ def _scale_evidence(
584
+ self,
585
+ adapted: FeaturePyramid,
586
+ unadapted: FeaturePyramid,
587
+ evidence: FeaturePyramid | None,
588
+ ) -> FeaturePyramid:
589
+ projected = []
590
+ for scale_idx, projection in enumerate(self.mask_evidence_proj):
591
+ if evidence is None:
592
+ evidence_i = torch.zeros(
593
+ adapted[scale_idx].shape[0],
594
+ self.hidden_dim,
595
+ adapted[scale_idx].shape[-2],
596
+ adapted[scale_idx].shape[-1],
597
+ dtype=adapted[scale_idx].dtype,
598
+ device=adapted[scale_idx].device,
599
+ )
600
+ else:
601
+ evidence_i = evidence[scale_idx]
602
+ if evidence_i.shape[-2:] != adapted[scale_idx].shape[-2:]:
603
+ evidence_i = F.interpolate(
604
+ evidence_i,
605
+ size=adapted[scale_idx].shape[-2:],
606
+ mode="bilinear",
607
+ align_corners=False,
608
+ )
609
+ adapter_delta = adapted[scale_idx] - unadapted[scale_idx]
610
+ projected.append(
611
+ projection(torch.cat([evidence_i, adapted[scale_idx], adapter_delta.abs()], dim=1))
612
+ )
613
+ return projected
614
+
615
+ def forward(
616
+ self,
617
+ adapted: FeaturePyramid,
618
+ streams_unadapted: StreamPyramid,
619
+ unadapted: FeaturePyramid,
620
+ evidence: FeaturePyramid | None = None,
621
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
622
+ del streams_unadapted
623
+ high_target = self.output_resolution
624
+ scale_features = self._scale_evidence(adapted, unadapted, evidence)
625
+
626
+ low = self.low_blocks(scale_features[0])
627
+ mid = scale_features[2] + F.interpolate(
628
+ low,
629
+ size=scale_features[2].shape[-2:],
630
+ mode="bilinear",
631
+ align_corners=False,
632
+ )
633
+ mid = self.mid_fuse(torch.cat([mid, scale_features[2]], dim=1))
634
+ mid = self.mid_blocks(mid)
635
+
636
+ high = scale_features[1] + F.interpolate(
637
+ mid,
638
+ size=scale_features[1].shape[-2:],
639
+ mode="bilinear",
640
+ align_corners=False,
641
+ )
642
+ high = self.high_fuse(torch.cat([high, scale_features[1]], dim=1))
643
+ high = self.high_blocks(high)
644
+
645
+ if high.shape[-2:] != high_target:
646
+ high = F.interpolate(high, size=high_target, mode="bilinear", align_corners=False)
647
+ mask_logits = self.mask_head(high)
648
+ aux_logits = self.aux_head(mid)
649
+ return mask_logits, aux_logits, None
650
+
651
+
detectivesam_inference/models/forgerylocalizer.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+ from sam2.build_sam import build_sam2
7
+ from sam2.modeling.backbones.image_encoder import ImageEncoder
8
+ from sam2.modeling.sam.mask_decoder import MaskDecoder
9
+ from sam2.modeling.sam.prompt_encoder import PromptEncoder
10
+ from sam2.modeling.sam2_base import SAM2Base
11
+ from sam2.utils.transforms import SAM2Transforms
12
+
13
+ from detectivesam_inference.models.adapters_v1 import MaskAdapter, SharedAdapter
14
+ from detectivesam_inference.models.adapters_v2 import (
15
+ CrossAttentionSharedAdapter,
16
+ SpatialCrossAttentionSharedAdapter,
17
+ StreamEvidenceBuilder,
18
+ TransformerEvidenceMaskAdapter,
19
+ )
20
+
21
+
22
+ MODEL_CHANNELS = [256, 32, 64]
23
+ MASK_ADAPTER_RESOLUTION = (128, 128)
24
+ FeaturePyramid = list[torch.Tensor]
25
+ StreamPyramid = list[list[torch.Tensor]]
26
+
27
+
28
+ class ForgeryLocalizer(nn.Module):
29
+ """Inference-only DetectiveSAM model."""
30
+
31
+ def __init__(
32
+ self,
33
+ sam_config: str,
34
+ sam_checkpoint: str,
35
+ prompt_dim: int = 256,
36
+ output_resolution: tuple[int, int] = (512, 512),
37
+ downscale: int = 4,
38
+ dropout_rate: float = 0.1,
39
+ max_streams: int = 2,
40
+ adapter_type: str = "conv",
41
+ mask_adapter_type: str = "coarse",
42
+ device: str = "cpu",
43
+ ) -> None:
44
+ super().__init__()
45
+ self.output_resolution = output_resolution
46
+ self.mask_adapter_type = mask_adapter_type
47
+
48
+ sam = self._build_sam(
49
+ sam_config=sam_config,
50
+ sam_checkpoint=sam_checkpoint,
51
+ output_resolution=output_resolution,
52
+ device=device,
53
+ )
54
+ self.no_mem_embed = sam.no_mem_embed if hasattr(sam, "no_mem_embed") else None
55
+ self.encoder: ImageEncoder = sam.image_encoder
56
+ self.decoder: MaskDecoder = sam.sam_mask_decoder
57
+ self.sam_prompt_encoder: PromptEncoder = sam.sam_prompt_encoder
58
+ self.sam_prompt_encoder.image_embedding_size = MASK_ADAPTER_RESOLUTION
59
+
60
+ self._freeze_sam_components()
61
+ self.adapters = self._build_feature_adapter(
62
+ adapter_type=adapter_type,
63
+ prompt_dim=prompt_dim,
64
+ dropout_rate=dropout_rate,
65
+ max_streams=max_streams,
66
+ )
67
+ self.evidence_builder = (
68
+ StreamEvidenceBuilder(
69
+ in_channels_list=MODEL_CHANNELS,
70
+ hidden_dim=prompt_dim,
71
+ dropout_rate=dropout_rate,
72
+ max_streams=max_streams,
73
+ )
74
+ if self._uses_evidence(adapter_type=adapter_type, mask_adapter_type=mask_adapter_type)
75
+ else None
76
+ )
77
+ self.mask_adapter = self._build_mask_adapter(
78
+ mask_adapter_type=mask_adapter_type,
79
+ prompt_dim=prompt_dim,
80
+ downscale=downscale,
81
+ dropout_rate=dropout_rate,
82
+ max_streams=max_streams,
83
+ )
84
+ self.transforms = SAM2Transforms(resolution=sam.image_size, mask_threshold=0.0)
85
+
86
+ @staticmethod
87
+ def _build_sam(
88
+ *,
89
+ sam_config: str,
90
+ sam_checkpoint: str,
91
+ output_resolution: tuple[int, int],
92
+ device: str,
93
+ ) -> SAM2Base:
94
+ sam: SAM2Base = build_sam2(sam_config, sam_checkpoint, device=device)
95
+ sam.image_size = output_resolution[0]
96
+ return sam
97
+
98
+ def _freeze_sam_components(self) -> None:
99
+ for module in (self.encoder, self.decoder, self.sam_prompt_encoder):
100
+ for parameter in module.parameters():
101
+ parameter.requires_grad = False
102
+
103
+ @staticmethod
104
+ def _uses_evidence(*, adapter_type: str, mask_adapter_type: str) -> bool:
105
+ return adapter_type != "conv" or mask_adapter_type == "transformer"
106
+
107
+ @staticmethod
108
+ def _build_feature_adapter(
109
+ *,
110
+ adapter_type: str,
111
+ prompt_dim: int,
112
+ dropout_rate: float,
113
+ max_streams: int,
114
+ ) -> nn.Module:
115
+ adapter_classes = {
116
+ "conv": SharedAdapter,
117
+ "cross_attention": CrossAttentionSharedAdapter,
118
+ "spatial_cross_attention": SpatialCrossAttentionSharedAdapter,
119
+ }
120
+ if adapter_type not in adapter_classes:
121
+ raise ValueError(f"Unknown adapter_type: {adapter_type}")
122
+ return adapter_classes[adapter_type](
123
+ hidden_dim=prompt_dim,
124
+ in_channels_list=MODEL_CHANNELS,
125
+ dropout_rate=dropout_rate,
126
+ max_streams=max_streams,
127
+ )
128
+
129
+ @staticmethod
130
+ def _build_mask_adapter(
131
+ *,
132
+ mask_adapter_type: str,
133
+ prompt_dim: int,
134
+ downscale: int,
135
+ dropout_rate: float,
136
+ max_streams: int,
137
+ ) -> nn.Module:
138
+ if mask_adapter_type == "coarse":
139
+ return MaskAdapter(
140
+ hidden_dim=prompt_dim,
141
+ output_resolution=MASK_ADAPTER_RESOLUTION,
142
+ downscale=downscale,
143
+ in_channels_list=MODEL_CHANNELS,
144
+ dropout_rate=dropout_rate,
145
+ max_streams=max_streams,
146
+ )
147
+ if mask_adapter_type == "transformer":
148
+ del downscale, max_streams
149
+ return TransformerEvidenceMaskAdapter(
150
+ hidden_dim=prompt_dim,
151
+ output_resolution=MASK_ADAPTER_RESOLUTION,
152
+ in_channels_list=MODEL_CHANNELS,
153
+ dropout_rate=dropout_rate,
154
+ )
155
+ raise ValueError(f"Unknown mask_adapter_type: {mask_adapter_type}")
156
+
157
+ def _project_sam_features(self, backbone_features: FeaturePyramid) -> tuple[torch.Tensor, FeaturePyramid]:
158
+ image_embeddings = backbone_features[-1]
159
+ if self.no_mem_embed is not None:
160
+ image_embeddings = image_embeddings + self.no_mem_embed.reshape(1, 256, 1, 1).detach()
161
+
162
+ high_res_features = [
163
+ self.decoder.conv_s0(backbone_features[0]),
164
+ self.decoder.conv_s1(backbone_features[1]),
165
+ ]
166
+ return image_embeddings, high_res_features
167
+
168
+ def _encode_original_and_streams(
169
+ self,
170
+ orig: torch.Tensor,
171
+ streams: list[torch.Tensor],
172
+ ) -> tuple[FeaturePyramid, StreamPyramid]:
173
+ orig_backbone_features = self.encoder(orig)["backbone_fpn"]
174
+ orig_image_embeddings, orig_high_res_features = self._project_sam_features(orig_backbone_features)
175
+
176
+ stream_image_embeddings: list[torch.Tensor] = []
177
+ stream_high_res_features_0: list[torch.Tensor] = []
178
+ stream_high_res_features_1: list[torch.Tensor] = []
179
+ for stream in streams:
180
+ stream_backbone_features = self.encoder(stream)["backbone_fpn"]
181
+ stream_image_embedding, stream_high_res_features = self._project_sam_features(stream_backbone_features)
182
+ stream_image_embeddings.append(stream_image_embedding)
183
+ stream_high_res_features_0.append(stream_high_res_features[0])
184
+ stream_high_res_features_1.append(stream_high_res_features[1])
185
+
186
+ unadapted = [orig_image_embeddings, orig_high_res_features[0], orig_high_res_features[1]]
187
+ streams_unadapted = [stream_image_embeddings, stream_high_res_features_0, stream_high_res_features_1]
188
+ return unadapted, streams_unadapted
189
+
190
+ def _apply_adapters(
191
+ self,
192
+ unadapted: FeaturePyramid,
193
+ streams_unadapted: StreamPyramid,
194
+ evidence: FeaturePyramid | None,
195
+ ) -> FeaturePyramid:
196
+ if evidence is None:
197
+ return [
198
+ self.adapters(streams_unadapted[scale_idx], unadapted[scale_idx], scale_idx)
199
+ for scale_idx in range(len(unadapted))
200
+ ]
201
+ return [
202
+ self.adapters(
203
+ streams_unadapted[scale_idx],
204
+ unadapted[scale_idx],
205
+ scale_idx,
206
+ evidence=evidence[scale_idx],
207
+ )
208
+ for scale_idx in range(len(unadapted))
209
+ ]
210
+
211
+ def _build_mask_prompt(
212
+ self,
213
+ adapted: FeaturePyramid,
214
+ streams_unadapted: StreamPyramid,
215
+ unadapted: FeaturePyramid,
216
+ evidence: FeaturePyramid | None,
217
+ ) -> tuple[torch.Tensor, dict[str, torch.Tensor | None]]:
218
+ if self.mask_adapter_type == "transformer":
219
+ mask_prompt, coarse_mask, detection_logit = self.mask_adapter(
220
+ adapted,
221
+ streams_unadapted,
222
+ unadapted,
223
+ evidence=evidence,
224
+ )
225
+ return mask_prompt, {
226
+ "coarse_mask": coarse_mask,
227
+ "detection_logit": detection_logit,
228
+ }
229
+
230
+ mask_prompt = self.mask_adapter(adapted, streams_unadapted, unadapted)
231
+ return mask_prompt, {"coarse_mask": None, "detection_logit": None}
232
+
233
+ def _prepare_decoder_inputs(
234
+ self,
235
+ adapted: FeaturePyramid,
236
+ mask_prompt: torch.Tensor,
237
+ ) -> tuple[torch.Tensor, FeaturePyramid, torch.Tensor]:
238
+ # Preserve the original interpolation steps so inference stays bit-exact.
239
+ image_embeddings = F.interpolate(
240
+ adapted[0],
241
+ size=adapted[0].shape[-2:],
242
+ mode="bilinear",
243
+ align_corners=False,
244
+ )
245
+ high_res_features = [
246
+ F.interpolate(
247
+ feature,
248
+ size=feature.shape[-2:],
249
+ mode="bilinear",
250
+ align_corners=False,
251
+ )
252
+ for feature in adapted[1:]
253
+ ]
254
+ mask_prompt = F.interpolate(
255
+ mask_prompt,
256
+ size=mask_prompt.shape[-2:],
257
+ mode="nearest",
258
+ )
259
+ return image_embeddings, high_res_features, mask_prompt
260
+
261
+ def forward(
262
+ self,
263
+ orig: torch.Tensor,
264
+ streams: list[torch.Tensor],
265
+ output_extras: bool = False,
266
+ ) -> torch.Tensor | tuple[torch.Tensor, dict[str, torch.Tensor | None]]:
267
+ unadapted, streams_unadapted = self._encode_original_and_streams(orig, streams)
268
+ evidence = (
269
+ self.evidence_builder(unadapted, streams_unadapted)
270
+ if self.evidence_builder is not None
271
+ else None
272
+ )
273
+ adapted = self._apply_adapters(unadapted, streams_unadapted, evidence)
274
+
275
+ mask_prompt, extras = self._build_mask_prompt(adapted, streams_unadapted, unadapted, evidence)
276
+ image_embeddings, high_res_features, mask_prompt = self._prepare_decoder_inputs(adapted, mask_prompt)
277
+
278
+ self.sam_prompt_encoder.image_embedding_size = image_embeddings.shape[-2:]
279
+ sparse_prompt_embeddings, dense_prompt_embeddings = self.sam_prompt_encoder(
280
+ points=None,
281
+ boxes=None,
282
+ masks=mask_prompt,
283
+ )
284
+
285
+ mask_logits, iou_pred, sam_tokens_out, object_score_logits = self.decoder(
286
+ image_embeddings=image_embeddings,
287
+ image_pe=self.sam_prompt_encoder.get_dense_pe(),
288
+ sparse_prompt_embeddings=sparse_prompt_embeddings,
289
+ dense_prompt_embeddings=dense_prompt_embeddings,
290
+ multimask_output=False,
291
+ repeat_image=False,
292
+ high_res_features=high_res_features,
293
+ )
294
+ processed_mask_logits = self.transforms.postprocess_masks(mask_logits, torch.Size(self.output_resolution))
295
+ if output_extras:
296
+ extras.update(
297
+ {
298
+ "iou_pred": iou_pred,
299
+ "sam_tokens_out": sam_tokens_out,
300
+ "object_score_logits": object_score_logits,
301
+ "mask_prompt": mask_prompt,
302
+ }
303
+ )
304
+ return processed_mask_logits, extras
305
+ return processed_mask_logits
detectivesam_inference/perturbations.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ import torchvision.transforms.functional as TF
8
+ from PIL import Image
9
+
10
+
11
+ def create_spatial_gaussian_kernel(
12
+ kernel_size: int,
13
+ sigma: float,
14
+ channels: int,
15
+ device: torch.device,
16
+ ) -> torch.Tensor:
17
+ radius = kernel_size // 2
18
+ coords = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32)
19
+ x_pos, y_pos = torch.meshgrid(coords, coords, indexing="ij")
20
+ gaussian = torch.exp(-(x_pos**2 + y_pos**2) / (2 * sigma**2))
21
+ gaussian /= gaussian.sum()
22
+ return gaussian.view(1, 1, kernel_size, kernel_size).repeat(channels, 1, 1, 1)
23
+
24
+
25
+ def apply_spatial_gaussian_blur(
26
+ video_tensor: torch.Tensor,
27
+ sigma: float,
28
+ kernel_sizes: tuple[int, ...] = (5,),
29
+ ) -> torch.Tensor:
30
+ batch_size, channels, frames, height, width = video_tensor.shape
31
+ blurred = video_tensor
32
+ for kernel_size in kernel_sizes:
33
+ kernel = create_spatial_gaussian_kernel(kernel_size, sigma, channels, video_tensor.device)
34
+ padding = kernel_size // 2
35
+ reshaped = blurred.permute(0, 2, 1, 3, 4).reshape(batch_size * frames, channels, height, width)
36
+ reshaped = F.conv2d(reshaped, kernel, padding=padding, groups=channels)
37
+ blurred = reshaped.reshape(batch_size, frames, channels, height, width).permute(0, 2, 1, 3, 4)
38
+ return blurred
39
+
40
+
41
+ def add_gaussian_noise_deterministic(
42
+ image: torch.Tensor,
43
+ *,
44
+ mean: float = 0.0,
45
+ std: float = 0.1,
46
+ seed: int,
47
+ ) -> torch.Tensor:
48
+ generator = torch.Generator(device=image.device)
49
+ generator.manual_seed(seed)
50
+ noise = torch.randn(image.shape, generator=generator, device=image.device, dtype=image.dtype) * std + mean
51
+ return torch.clamp(image + noise, 0.0, 1.0)
52
+
53
+
54
+ def apply_blur_to_image_tensor(image_tensor: torch.Tensor, sigma: float) -> torch.Tensor:
55
+ if sigma <= 0:
56
+ return image_tensor.clone()
57
+ video_tensor = image_tensor.unsqueeze(0).unsqueeze(2)
58
+ blurred = apply_spatial_gaussian_blur(video_tensor, sigma=sigma, kernel_sizes=(5,))
59
+ return blurred.squeeze(0).squeeze(1)
60
+
61
+
62
+ def apply_jpeg_compression(image: Image.Image, quality: int = 75) -> Image.Image:
63
+ buffer = io.BytesIO()
64
+ image.save(buffer, format="JPEG", quality=quality)
65
+ buffer.seek(0)
66
+ return Image.open(buffer).convert("RGB")
67
+
68
+
69
+ def apply_jpeg_compression_to_tensor(image_tensor: torch.Tensor, quality: int) -> torch.Tensor:
70
+ image = TF.to_pil_image(image_tensor)
71
+ return TF.to_tensor(apply_jpeg_compression(image, quality=quality))
detectivesam_inference/predict.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from detectivesam_inference.dataset import prepare_sample
8
+ from detectivesam_inference.metrics import compute_f1, compute_iou
9
+ from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
10
+ from detectivesam_inference.visualization import save_prediction_outputs
11
+
12
+
13
+ DEFAULT_DEMO_NAME = "banana_28809"
14
+
15
+
16
+ def resolve_demo_defaults(repo_root: Path) -> tuple[Path | None, Path, Path | None, str]:
17
+ user_demo_target = repo_root / "demo" / "user_image" / "demo_input.png"
18
+ fallback_source = repo_root / "demo" / "cocoglide" / "source" / f"{DEFAULT_DEMO_NAME}.png"
19
+ fallback_target = repo_root / "demo" / "cocoglide" / "target" / f"{DEFAULT_DEMO_NAME}.png"
20
+ fallback_mask = repo_root / "demo" / "cocoglide" / "mask" / f"{DEFAULT_DEMO_NAME}.png"
21
+
22
+ if user_demo_target.exists():
23
+ return None, user_demo_target, None, "single_image"
24
+ return fallback_source, fallback_target, fallback_mask, "pair"
25
+
26
+
27
+ def parse_args() -> argparse.Namespace:
28
+ repo_root = get_repo_root()
29
+ parser = argparse.ArgumentParser(description="Run DetectiveSAM on a single source/target pair.")
30
+ parser.add_argument(
31
+ "--checkpoint",
32
+ default="detective_sam_v2",
33
+ help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota, detective_sam_v2.",
34
+ )
35
+ parser.add_argument("--source", default=None, help="Optional source image. If omitted, target is reused as source.")
36
+ parser.add_argument(
37
+ "--target",
38
+ default=None,
39
+ help="Target image. If omitted, uses demo/user_image/demo_input.png when present, else falls back to the bundled CocoGlide pair.",
40
+ )
41
+ parser.add_argument("--mask", default=None, help="Optional ground-truth mask for metrics.")
42
+ parser.add_argument("--output-dir", default=str(repo_root / "outputs" / "predict_demo"))
43
+ parser.add_argument("--device", default=None)
44
+ parser.add_argument("--threshold", type=float, default=0.5)
45
+ return parser.parse_args()
46
+
47
+
48
+ def resolve_input_paths(args: argparse.Namespace, repo_root: Path) -> tuple[Path, Path, Path | None, str]:
49
+ demo_source, demo_target, demo_mask, demo_mode = resolve_demo_defaults(repo_root)
50
+ if args.target is not None:
51
+ target_path = Path(args.target)
52
+ source_path = Path(args.source) if args.source else target_path
53
+ mask_path = Path(args.mask) if args.mask else None
54
+ return source_path, target_path, mask_path, "custom"
55
+
56
+ target_path = demo_target
57
+ source_path = Path(args.source) if args.source else (demo_source or target_path)
58
+ mask_path = Path(args.mask) if args.mask else demo_mask
59
+ return source_path, target_path, mask_path, demo_mode
60
+
61
+
62
+ def main() -> None:
63
+ args = parse_args()
64
+ repo_root = get_repo_root()
65
+ source_path, target_path, mask_path, demo_mode = resolve_input_paths(args, repo_root)
66
+ reference_mode = "pair" if source_path != target_path else "target_as_source"
67
+
68
+ runner = DetectiveSAMRunner(checkpoint_path=args.checkpoint, device=args.device)
69
+ sample = prepare_sample(
70
+ source_path=source_path,
71
+ target_path=target_path,
72
+ mask_path=mask_path,
73
+ img_size=runner.config.img_size,
74
+ perturbation_type=runner.config.perturbation_type,
75
+ perturbation_intensity=runner.config.perturbation_intensity,
76
+ )
77
+ prediction = runner.predict_sample(sample, threshold=args.threshold)
78
+
79
+ gt_mask = sample.mask.squeeze().numpy().astype("uint8") if sample.mask is not None else None
80
+ summary = {
81
+ "sample": sample.name,
82
+ "checkpoint": str(runner.checkpoint_path.resolve()),
83
+ "demo_mode": demo_mode,
84
+ "reference_mode": reference_mode,
85
+ "source": str(sample.source_path),
86
+ "target": str(sample.target_path),
87
+ "mask": str(sample.mask_path) if sample.mask_path is not None else None,
88
+ "threshold": args.threshold,
89
+ "metrics": {
90
+ "iou": compute_iou(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
91
+ "f1": compute_f1(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
92
+ },
93
+ }
94
+
95
+ output_dir = Path(args.output_dir)
96
+ save_prediction_outputs(
97
+ output_dir=output_dir,
98
+ name=sample.name,
99
+ source_image=sample.source_image,
100
+ target_image=sample.target_image,
101
+ probability_map=prediction.probability,
102
+ pred_mask=prediction.pred_mask,
103
+ gt_mask=gt_mask,
104
+ )
105
+ output_dir.mkdir(parents=True, exist_ok=True)
106
+ with (output_dir / f"{sample.name}_summary.json").open("w", encoding="utf-8") as handle:
107
+ json.dump(summary, handle, indent=2)
108
+
109
+ print(json.dumps(summary, indent=2))
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
detectivesam_inference/runtime.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from contextlib import nullcontext
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+ from hydra import initialize_config_dir
10
+ from hydra.core.global_hydra import GlobalHydra
11
+
12
+ from detectivesam_inference.checkpoint import (
13
+ InferenceConfig,
14
+ load_inference_config,
15
+ resolve_checkpoint_path,
16
+ resolve_repo_path,
17
+ )
18
+ from detectivesam_inference.dataset import PreparedSample
19
+ from detectivesam_inference.models.forgerylocalizer import ForgeryLocalizer
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PredictionResult:
24
+ probability: np.ndarray
25
+ pred_mask: np.ndarray
26
+
27
+
28
+ def get_repo_root() -> Path:
29
+ return Path(__file__).resolve().parent.parent
30
+
31
+
32
+ def select_device(device: str | None = None) -> torch.device:
33
+ if device is not None:
34
+ return torch.device(device)
35
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
36
+
37
+
38
+ def initialize_sam2_config(config_dir: str | Path) -> None:
39
+ config_dir = str(Path(config_dir).resolve())
40
+ hydra = GlobalHydra.instance()
41
+ current_dir = getattr(initialize_sam2_config, "_current_dir", None)
42
+ if hydra.is_initialized():
43
+ if current_dir == config_dir:
44
+ return
45
+ hydra.clear()
46
+ initialize_config_dir(config_dir=config_dir, version_base=None)
47
+ initialize_sam2_config._current_dir = config_dir
48
+
49
+
50
+ class DetectiveSAMRunner:
51
+ def __init__(
52
+ self,
53
+ checkpoint_path: str | Path | None = None,
54
+ device: str | None = None,
55
+ ) -> None:
56
+ self.repo_root = get_repo_root()
57
+ self.checkpoint_path = resolve_checkpoint_path(checkpoint_path, self.repo_root)
58
+ self.device = select_device(device)
59
+ self.config = load_inference_config(self.checkpoint_path)
60
+ self.model = self._load_model()
61
+
62
+ def _load_model(self) -> ForgeryLocalizer:
63
+ sam_config_path = resolve_repo_path(self.config.sam_config_file, self.repo_root)
64
+ sam_checkpoint_path = resolve_repo_path(self.config.sam_checkpoint, self.repo_root)
65
+ initialize_sam2_config(sam_config_path.parent)
66
+
67
+ model = ForgeryLocalizer(
68
+ sam_config=sam_config_path.name,
69
+ sam_checkpoint=str(sam_checkpoint_path),
70
+ prompt_dim=self.config.prompt_dim,
71
+ downscale=self.config.downscale,
72
+ dropout_rate=self.config.dropout_rate,
73
+ max_streams=self.config.max_streams,
74
+ adapter_type=self.config.adapter_type,
75
+ mask_adapter_type=self.config.mask_adapter_type,
76
+ device=str(self.device),
77
+ ).to(self.device)
78
+
79
+ checkpoint = torch.load(self.checkpoint_path, map_location=self.device, weights_only=False)
80
+ state_dict = self._select_state_dict(checkpoint)
81
+ state_dict = self._clean_state_dict_prefixes(state_dict)
82
+ try:
83
+ model.load_state_dict(state_dict)
84
+ except RuntimeError:
85
+ self._load_compatible_state_dict(model, state_dict)
86
+ model.eval()
87
+ return model
88
+
89
+ @staticmethod
90
+ def _select_state_dict(checkpoint) -> dict[str, torch.Tensor]:
91
+ if isinstance(checkpoint, dict):
92
+ for key in ("model", "ema", "model_state_dict", "state_dict"):
93
+ if key in checkpoint:
94
+ return checkpoint[key]
95
+ return checkpoint
96
+
97
+ @staticmethod
98
+ def _clean_state_dict_prefixes(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
99
+ return {
100
+ key.removeprefix("module.").removeprefix("_orig_mod."): value
101
+ for key, value in state_dict.items()
102
+ }
103
+
104
+ @staticmethod
105
+ def _load_compatible_state_dict(
106
+ model: ForgeryLocalizer,
107
+ state_dict: dict[str, torch.Tensor],
108
+ ) -> None:
109
+ current_state = model.state_dict()
110
+ compatible = {
111
+ key: value
112
+ for key, value in state_dict.items()
113
+ if key in current_state and current_state[key].shape == value.shape
114
+ }
115
+ if not compatible:
116
+ raise RuntimeError("Checkpoint did not contain any tensors compatible with the selected architecture.")
117
+ model.load_state_dict(compatible, strict=False)
118
+
119
+ def autocast_context(self):
120
+ if self.device.type == "cuda":
121
+ return torch.amp.autocast(device_type="cuda")
122
+ return nullcontext()
123
+
124
+ def predict_sample(
125
+ self,
126
+ sample: PreparedSample,
127
+ threshold: float = 0.5,
128
+ ) -> PredictionResult:
129
+ orig = sample.orig.unsqueeze(0).to(self.device)
130
+ streams = [stream.unsqueeze(0).to(self.device) for stream in sample.streams]
131
+
132
+ with torch.inference_mode():
133
+ with self.autocast_context():
134
+ logits = self.model(orig, streams)
135
+
136
+ probability = torch.sigmoid(logits).squeeze().detach().cpu().numpy()
137
+ pred_mask = (probability > threshold).astype("uint8")
138
+ return PredictionResult(probability=probability, pred_mask=pred_mask)
detectivesam_inference/visualization.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ from PIL import Image, ImageOps
7
+
8
+
9
+ def ensure_uint8(mask: np.ndarray) -> np.ndarray:
10
+ if mask.dtype == np.bool_:
11
+ return mask.astype(np.uint8) * 255
12
+ if mask.max() <= 1.0:
13
+ return (mask * 255).astype(np.uint8)
14
+ return mask.astype(np.uint8)
15
+
16
+
17
+ def mask_to_image(mask: np.ndarray) -> Image.Image:
18
+ return Image.fromarray(ensure_uint8(mask), mode="L")
19
+
20
+
21
+ def probability_to_image(probability: np.ndarray) -> Image.Image:
22
+ clipped = np.clip(probability, 0.0, 1.0)
23
+ return Image.fromarray((clipped * 255).astype(np.uint8), mode="L")
24
+
25
+
26
+ def overlay_mask(
27
+ image: Image.Image,
28
+ mask: np.ndarray,
29
+ color: tuple[int, int, int],
30
+ alpha: float = 0.45,
31
+ ) -> Image.Image:
32
+ base = np.array(image.convert("RGB"), dtype=np.float32)
33
+ overlay = base.copy()
34
+ overlay[mask.astype(bool)] = (1.0 - alpha) * overlay[mask.astype(bool)] + alpha * np.array(color, dtype=np.float32)
35
+ return Image.fromarray(np.clip(overlay, 0, 255).astype(np.uint8), mode="RGB")
36
+
37
+
38
+ def concat_images(images: list[Image.Image]) -> Image.Image:
39
+ widths, heights = zip(*(image.size for image in images))
40
+ canvas = Image.new("RGB", (sum(widths), max(heights)), color=(255, 255, 255))
41
+ x_offset = 0
42
+ for image in images:
43
+ canvas.paste(image.convert("RGB"), (x_offset, 0))
44
+ x_offset += image.width
45
+ return canvas
46
+
47
+
48
+ def save_prediction_outputs(
49
+ output_dir: str | Path,
50
+ name: str,
51
+ source_image: Image.Image,
52
+ target_image: Image.Image,
53
+ probability_map: np.ndarray,
54
+ pred_mask: np.ndarray,
55
+ gt_mask: np.ndarray | None = None,
56
+ ) -> None:
57
+ output_dir = Path(output_dir)
58
+ output_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ probability_image = probability_to_image(probability_map)
61
+ pred_mask_image = mask_to_image(pred_mask)
62
+ pred_overlay = overlay_mask(target_image, pred_mask, color=(255, 0, 0))
63
+
64
+ comparison_images = [
65
+ source_image.convert("RGB"),
66
+ target_image.convert("RGB"),
67
+ ImageOps.colorize(probability_image, black="black", white="white").convert("RGB"),
68
+ pred_overlay,
69
+ ]
70
+
71
+ probability_image.save(output_dir / f"{name}_probability.png")
72
+ pred_mask_image.save(output_dir / f"{name}_pred_mask.png")
73
+ pred_overlay.save(output_dir / f"{name}_pred_overlay.png")
74
+
75
+ if gt_mask is not None:
76
+ gt_mask_image = mask_to_image(gt_mask)
77
+ gt_overlay = overlay_mask(target_image, gt_mask, color=(0, 255, 0))
78
+ gt_mask_image.save(output_dir / f"{name}_gt_mask.png")
79
+ gt_overlay.save(output_dir / f"{name}_gt_overlay.png")
80
+ comparison_images.append(gt_overlay)
81
+
82
+ concat_images(comparison_images).save(output_dir / f"{name}_comparison.png")
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ torch>=2.5.1,<2.8
2
+ torchvision>=0.20.1,<0.23
3
+ numpy>=1.24
4
+ Pillow>=10.0
5
+ opencv-python-headless>=4.10
6
+ hydra-core>=1.3
7
+ sam2
8
+ tqdm>=4.66
sam2configs/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SAM2 Weights
2
+
3
+ The Hugging Face bundle ships the SAM2 Hiera Base Plus checkpoint.
4
+
5
+ The full runnable bundle is hosted on Hugging Face:
6
+
7
+ - https://huggingface.co/Gertlek/DetectiveSAMv2
8
+
9
+ To run inference from a lightweight checkout, download the checkpoint file from Hugging Face and place it in this directory with this exact name:
10
+
11
+ - `sam2.1_hiera_base_plus.pt`
12
+
13
+ The bundled inference configs already point to that path.
sam2configs/sam2.1_hiera_b+.yaml ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # @package _global_
2
+
3
+ # Model
4
+ model:
5
+ _target_: sam2.modeling.sam2_base.SAM2Base
6
+ image_encoder:
7
+ _target_: sam2.modeling.backbones.image_encoder.ImageEncoder
8
+ scalp: 1
9
+ trunk:
10
+ _target_: sam2.modeling.backbones.hieradet.Hiera
11
+ embed_dim: 112
12
+ num_heads: 2
13
+ neck:
14
+ _target_: sam2.modeling.backbones.image_encoder.FpnNeck
15
+ position_encoding:
16
+ _target_: sam2.modeling.position_encoding.PositionEmbeddingSine
17
+ num_pos_feats: 256
18
+ normalize: true
19
+ scale: null
20
+ temperature: 10000
21
+ d_model: 256
22
+ backbone_channel_list: [896, 448, 224, 112]
23
+ fpn_top_down_levels: [2, 3] # output level 0 and 1 directly use the backbone features
24
+ fpn_interp_model: nearest
25
+
26
+ memory_attention:
27
+ _target_: sam2.modeling.memory_attention.MemoryAttention
28
+ d_model: 256
29
+ pos_enc_at_input: true
30
+ layer:
31
+ _target_: sam2.modeling.memory_attention.MemoryAttentionLayer
32
+ activation: relu
33
+ dim_feedforward: 2048
34
+ dropout: 0.1
35
+ pos_enc_at_attn: false
36
+ self_attention:
37
+ _target_: sam2.modeling.sam.transformer.RoPEAttention
38
+ rope_theta: 10000.0
39
+ feat_sizes: [64, 64]
40
+ embedding_dim: 256
41
+ num_heads: 1
42
+ downsample_rate: 1
43
+ dropout: 0.1
44
+ d_model: 256
45
+ pos_enc_at_cross_attn_keys: true
46
+ pos_enc_at_cross_attn_queries: false
47
+ cross_attention:
48
+ _target_: sam2.modeling.sam.transformer.RoPEAttention
49
+ rope_theta: 10000.0
50
+ feat_sizes: [64, 64]
51
+ rope_k_repeat: True
52
+ embedding_dim: 256
53
+ num_heads: 1
54
+ downsample_rate: 1
55
+ dropout: 0.1
56
+ kv_in_dim: 64
57
+ num_layers: 4
58
+
59
+ memory_encoder:
60
+ _target_: sam2.modeling.memory_encoder.MemoryEncoder
61
+ out_dim: 64
62
+ position_encoding:
63
+ _target_: sam2.modeling.position_encoding.PositionEmbeddingSine
64
+ num_pos_feats: 64
65
+ normalize: true
66
+ scale: null
67
+ temperature: 10000
68
+ mask_downsampler:
69
+ _target_: sam2.modeling.memory_encoder.MaskDownSampler
70
+ kernel_size: 3
71
+ stride: 2
72
+ padding: 1
73
+ fuser:
74
+ _target_: sam2.modeling.memory_encoder.Fuser
75
+ layer:
76
+ _target_: sam2.modeling.memory_encoder.CXBlock
77
+ dim: 256
78
+ kernel_size: 7
79
+ padding: 3
80
+ layer_scale_init_value: 1e-6
81
+ use_dwconv: True # depth-wise convs
82
+ num_layers: 2
83
+
84
+ num_maskmem: 7
85
+ image_size: 1024
86
+ # apply scaled sigmoid on mask logits for memory encoder, and directly feed input mask as output mask
87
+ sigmoid_scale_for_mem_enc: 20.0
88
+ sigmoid_bias_for_mem_enc: -10.0
89
+ use_mask_input_as_output_without_sam: true
90
+ # Memory
91
+ directly_add_no_mem_embed: true
92
+ no_obj_embed_spatial: true
93
+ # use high-resolution feature map in the SAM mask decoder
94
+ use_high_res_features_in_sam: true
95
+ # output 3 masks on the first click on initial conditioning frames
96
+ multimask_output_in_sam: true
97
+ # SAM heads
98
+ iou_prediction_use_sigmoid: True
99
+ # cross-attend to object pointers from other frames (based on SAM output tokens) in the encoder
100
+ use_obj_ptrs_in_encoder: true
101
+ add_tpos_enc_to_obj_ptrs: true
102
+ proj_tpos_enc_in_obj_ptrs: true
103
+ use_signed_tpos_enc_to_obj_ptrs: true
104
+ only_obj_ptrs_in_the_past_for_eval: true
105
+ # object occlusion prediction
106
+ pred_obj_scores: true
107
+ pred_obj_scores_mlp: true
108
+ fixed_no_obj_ptr: true
109
+ # multimask tracking settings
110
+ multimask_output_for_tracking: true
111
+ use_multimask_token_for_obj_ptr: true
112
+ multimask_min_pt_num: 0
113
+ multimask_max_pt_num: 1
114
+ use_mlp_for_obj_ptr_proj: true
115
+ # Compilation flag
116
+ compile_image_encoder: False
tests/test_regression.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from detectivesam_inference.checkpoint import load_inference_config, resolve_checkpoint_path
9
+ from detectivesam_inference.dataset import PairDataset, prepare_sample
10
+ from detectivesam_inference.metrics import compute_f1, compute_iou, summarize_results
11
+ from detectivesam_inference.models.adapters_v1 import MaskAdapter
12
+ from detectivesam_inference.models.adapters_v2 import TransformerEvidenceMaskAdapter
13
+ from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
14
+
15
+
16
+ def assert_exact(value: float | None, expected: float | None, *, abs_tol: float = 1e-12) -> None:
17
+ assert value is not None
18
+ assert expected is not None
19
+ assert math.isclose(value, expected, rel_tol=0.0, abs_tol=abs_tol)
20
+
21
+
22
+ @pytest.fixture(scope="module")
23
+ def repo_root() -> Path:
24
+ return get_repo_root()
25
+
26
+
27
+ @pytest.fixture(scope="module")
28
+ def baseline_runner() -> DetectiveSAMRunner:
29
+ checkpoint_path = resolve_checkpoint_path("detective_sam", get_repo_root())
30
+ if not checkpoint_path.exists():
31
+ pytest.skip(f"Missing optional checkpoint: {checkpoint_path}")
32
+ return DetectiveSAMRunner(checkpoint_path="detective_sam", device="cpu")
33
+
34
+
35
+ @pytest.fixture(scope="module")
36
+ def sota_runner() -> DetectiveSAMRunner:
37
+ checkpoint_path = resolve_checkpoint_path("detective_sam_sota", get_repo_root())
38
+ if not checkpoint_path.exists():
39
+ pytest.skip(f"Missing optional checkpoint: {checkpoint_path}")
40
+ return DetectiveSAMRunner(checkpoint_path="detective_sam_sota", device="cpu")
41
+
42
+
43
+ def predict_metrics(
44
+ runner: DetectiveSAMRunner,
45
+ *,
46
+ source_path: Path,
47
+ target_path: Path,
48
+ mask_path: Path,
49
+ ) -> tuple[float, float]:
50
+ sample = prepare_sample(
51
+ source_path=source_path,
52
+ target_path=target_path,
53
+ mask_path=mask_path,
54
+ img_size=runner.config.img_size,
55
+ perturbation_type=runner.config.perturbation_type,
56
+ perturbation_intensity=runner.config.perturbation_intensity,
57
+ )
58
+ prediction = runner.predict_sample(sample, threshold=0.5)
59
+ true_mask = sample.mask.squeeze().numpy().astype("uint8")
60
+ return compute_iou(prediction.pred_mask, true_mask), compute_f1(prediction.pred_mask, true_mask)
61
+
62
+
63
+ def test_checkpoint_alias_resolution(repo_root: Path) -> None:
64
+ assert resolve_checkpoint_path("detective_sam", repo_root) == repo_root / "checkpoints" / "model_epoch22_batch999_score1.1114.pth"
65
+ assert resolve_checkpoint_path("detective_sam_sota", repo_root) == repo_root / "checkpoints" / "detective_sam_sota.pth"
66
+ assert resolve_checkpoint_path("detective_sam_v2", repo_root) == repo_root / "checkpoints" / "detective_sam_v2.pth"
67
+
68
+
69
+ def test_v2_checkpoint_sidecar(repo_root: Path) -> None:
70
+ config = load_inference_config(repo_root / "checkpoints" / "detective_sam_v2.pth")
71
+ assert config.prompt_dim == 96
72
+ assert config.downscale == 8
73
+ assert config.max_streams == 3
74
+ assert config.perturbation_type == "gaussian_blur+jpeg_compression+gaussian_noise"
75
+ assert config.adapter_type == "spatial_cross_attention"
76
+ assert config.mask_adapter_type == "transformer"
77
+
78
+
79
+ def test_json_checkpoint_sidecar(tmp_path: Path) -> None:
80
+ checkpoint_path = tmp_path / "best_model.pth"
81
+ checkpoint_path.touch()
82
+ (tmp_path / "model_params.json").write_text(
83
+ """
84
+ {
85
+ "model_config": {
86
+ "prompt_dim": 96,
87
+ "downscale": 8,
88
+ "dropout_rate": 0.1,
89
+ "adapter_type": "spatial_cross_attention",
90
+ "mask_adapter_type": "transformer"
91
+ },
92
+ "training_config": {"img_size": 512},
93
+ "data_config": {
94
+ "perturbation_type": "gaussian_blur+jpeg_compression+gaussian_noise",
95
+ "perturbation_intensity": 0.5
96
+ },
97
+ "sam_config": {
98
+ "sam_config_file": "sam2.1_hiera_b+.yaml",
99
+ "sam_checkpoint": "sam2configs/sam2.1_hiera_base_plus.pt"
100
+ }
101
+ }
102
+ """,
103
+ encoding="utf-8",
104
+ )
105
+ config = load_inference_config(checkpoint_path)
106
+ assert config.prompt_dim == 96
107
+ assert config.max_streams == 3
108
+ assert config.adapter_type == "spatial_cross_attention"
109
+ assert config.mask_adapter_type == "transformer"
110
+
111
+
112
+ def test_adapter_modules_are_split() -> None:
113
+ assert MaskAdapter.__module__ == "detectivesam_inference.models.adapters_v1"
114
+ assert TransformerEvidenceMaskAdapter.__module__ == "detectivesam_inference.models.adapters_v2"
115
+
116
+
117
+ def test_baseline_banana_demo_metrics(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
118
+ demo_root = repo_root / "demo" / "cocoglide"
119
+ iou, f1 = predict_metrics(
120
+ baseline_runner,
121
+ source_path=demo_root / "source" / "banana_28809.png",
122
+ target_path=demo_root / "target" / "banana_28809.png",
123
+ mask_path=demo_root / "mask" / "banana_28809.png",
124
+ )
125
+ assert_exact(iou, 0.8566427949370513)
126
+ assert_exact(f1, 0.9227868680750683)
127
+
128
+
129
+ def test_sota_flux_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
130
+ demo_root = repo_root / "demo" / "flux_test"
131
+ iou, f1 = predict_metrics(
132
+ sota_runner,
133
+ source_path=demo_root / "source" / "548.png",
134
+ target_path=demo_root / "target" / "548.png",
135
+ mask_path=demo_root / "mask" / "548.png",
136
+ )
137
+ assert_exact(iou, 0.8703024868799283)
138
+ assert_exact(f1, 0.9306542583192329)
139
+
140
+
141
+ def test_sota_qwen_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
142
+ demo_root = repo_root / "demo" / "qwen_test"
143
+ iou, f1 = predict_metrics(
144
+ sota_runner,
145
+ source_path=demo_root / "source" / "166.png",
146
+ target_path=demo_root / "target" / "166.png",
147
+ mask_path=demo_root / "mask" / "166.png",
148
+ )
149
+ assert_exact(iou, 0.8297306693388413)
150
+ assert_exact(f1, 0.9069429542203147)
151
+
152
+
153
+ def test_baseline_cocoglide_eval_summary(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
154
+ dataset = PairDataset(
155
+ root_dir=repo_root / "demo" / "cocoglide",
156
+ img_size=baseline_runner.config.img_size,
157
+ perturbation_type=baseline_runner.config.perturbation_type,
158
+ perturbation_intensity=baseline_runner.config.perturbation_intensity,
159
+ )
160
+
161
+ per_sample_results: list[dict[str, float | str | None]] = []
162
+ for sample in dataset:
163
+ prediction = baseline_runner.predict_sample(sample, threshold=0.5)
164
+ true_mask = sample.mask.squeeze().numpy().astype("uint8")
165
+ per_sample_results.append(
166
+ {
167
+ "name": sample.name,
168
+ "iou": compute_iou(prediction.pred_mask, true_mask),
169
+ "f1": compute_f1(prediction.pred_mask, true_mask),
170
+ }
171
+ )
172
+
173
+ summary = summarize_results(per_sample_results)
174
+ assert summary["num_samples"] == 5
175
+ assert summary["num_samples_with_gt"] == 5
176
+ assert_exact(summary["mean_iou"], 0.5092573070481035)
177
+ assert_exact(summary["mean_f1"], 0.6509390858765342)
178
+
179
+ expected_by_name = {
180
+ "airplane_139871": (0.41829717560376584, 0.5898582931686339),
181
+ "banana_28809": (0.8566427949370513, 0.9227868680750683),
182
+ "giraffe_296969": (0.22833093957714018, 0.3717743031951054),
183
+ "train_221213": (0.547253866814856, 0.7073872989458688),
184
+ "tv_453722": (0.49576175830770386, 0.662888665997994),
185
+ }
186
+ for result in per_sample_results:
187
+ expected_iou, expected_f1 = expected_by_name[result["name"]]
188
+ assert_exact(result["iou"], expected_iou)
189
+ assert_exact(result["f1"], expected_f1)