Gertlek commited on
Commit
7b474fb
·
verified ·
1 Parent(s): fc9f384

Publish DetectiveSAM inference bundle

Browse files
Files changed (46) hide show
  1. .gitattributes +16 -35
  2. .gitignore +7 -0
  3. README.md +121 -0
  4. checkpoints/detective_sam_sota.pth +3 -0
  5. checkpoints/detective_sam_sota_params.yaml +8 -0
  6. checkpoints/model_epoch22_batch999_score1.1114.pth +3 -0
  7. checkpoints/model_epoch22_batch999_score1.1114_params.yaml +8 -0
  8. demo/cocoglide/mask/airplane_139871.png +0 -0
  9. demo/cocoglide/mask/banana_28809.png +0 -0
  10. demo/cocoglide/mask/giraffe_296969.png +0 -0
  11. demo/cocoglide/mask/train_221213.png +0 -0
  12. demo/cocoglide/mask/tv_453722.png +0 -0
  13. demo/cocoglide/source/airplane_139871.png +3 -0
  14. demo/cocoglide/source/banana_28809.png +3 -0
  15. demo/cocoglide/source/giraffe_296969.png +3 -0
  16. demo/cocoglide/source/train_221213.png +3 -0
  17. demo/cocoglide/source/tv_453722.png +3 -0
  18. demo/cocoglide/target/airplane_139871.png +3 -0
  19. demo/cocoglide/target/banana_28809.png +3 -0
  20. demo/cocoglide/target/giraffe_296969.png +3 -0
  21. demo/cocoglide/target/train_221213.png +3 -0
  22. demo/cocoglide/target/tv_453722.png +3 -0
  23. demo/flux_test/mask/548.png +0 -0
  24. demo/flux_test/source/548.png +3 -0
  25. demo/flux_test/target/548.png +3 -0
  26. demo/qwen_test/mask/166.png +0 -0
  27. demo/qwen_test/source/166.png +3 -0
  28. demo/qwen_test/target/166.png +3 -0
  29. demo/user_image/README.md +14 -0
  30. detectivesam_inference/__init__.py +4 -0
  31. detectivesam_inference/checkpoint.py +151 -0
  32. detectivesam_inference/dataset.py +249 -0
  33. detectivesam_inference/evaluate.py +86 -0
  34. detectivesam_inference/metrics.py +50 -0
  35. detectivesam_inference/models/__init__.py +3 -0
  36. detectivesam_inference/models/adapters.py +362 -0
  37. detectivesam_inference/models/forgerylocalizer.py +179 -0
  38. detectivesam_inference/perturbations.py +71 -0
  39. detectivesam_inference/predict.py +113 -0
  40. detectivesam_inference/runtime.py +102 -0
  41. detectivesam_inference/visualization.py +82 -0
  42. pytest.ini +2 -0
  43. requirements.txt +9 -0
  44. sam2configs/sam2.1_hiera_b+.yaml +116 -0
  45. sam2configs/sam2.1_hiera_base_plus.pt +3 -0
  46. tests/test_regression.py +132 -0
.gitattributes CHANGED
@@ -1,35 +1,16 @@
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/airplane_139871.png filter=lfs diff=lfs merge=lfs -text
4
+ demo/cocoglide/source/banana_28809.png filter=lfs diff=lfs merge=lfs -text
5
+ demo/cocoglide/source/giraffe_296969.png filter=lfs diff=lfs merge=lfs -text
6
+ demo/cocoglide/source/train_221213.png filter=lfs diff=lfs merge=lfs -text
7
+ demo/cocoglide/source/tv_453722.png filter=lfs diff=lfs merge=lfs -text
8
+ demo/cocoglide/target/airplane_139871.png filter=lfs diff=lfs merge=lfs -text
9
+ demo/cocoglide/target/banana_28809.png filter=lfs diff=lfs merge=lfs -text
10
+ demo/cocoglide/target/giraffe_296969.png filter=lfs diff=lfs merge=lfs -text
11
+ demo/cocoglide/target/train_221213.png filter=lfs diff=lfs merge=lfs -text
12
+ demo/cocoglide/target/tv_453722.png filter=lfs diff=lfs merge=lfs -text
13
+ demo/flux_test/source/548.png filter=lfs diff=lfs merge=lfs -text
14
+ demo/flux_test/target/548.png filter=lfs diff=lfs merge=lfs -text
15
+ demo/qwen_test/source/166.png filter=lfs diff=lfs merge=lfs -text
16
+ demo/qwen_test/target/166.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .venv/
5
+ venv/
6
+ .pytest_cache/
7
+ outputs/
README.md ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ library_name: pytorch
4
+ pipeline_tag: image-segmentation
5
+ tags:
6
+ - image-forensics
7
+ - image-manipulation-detection
8
+ - image-segmentation
9
+ - sam2
10
+ - pytorch
11
+ ---
12
+
13
+ # DetectiveSAM
14
+
15
+ DetectiveSAM is an inference-only image forgery localization bundle built around SAM2. This release includes bundled checkpoints and a small set of ready-to-run examples for demos.
16
+
17
+ ## What is bundled
18
+
19
+ - Inference checkpoints under `checkpoints/`
20
+ - SAM2 config and weights under `sam2configs/`
21
+ - Poster demo pairs under `demo/cocoglide/`, `demo/flux_test/`, and `demo/qwen_test/`
22
+ - A drop-in single-image slot at `demo/user_image/demo_input.png`
23
+
24
+ Built-in checkpoint aliases:
25
+
26
+ - `detective_sam`
27
+ - `detective_sam_sota`
28
+
29
+ ## Setup
30
+
31
+ ```bash
32
+ python -m venv .venv
33
+ source .venv/bin/activate
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ ## Hugging Face Usage
38
+
39
+ ```bash
40
+ git lfs install
41
+ git clone https://huggingface.co/Gertlek/DetectiveSAM
42
+ cd DetectiveSAM
43
+ python -m venv .venv
44
+ source .venv/bin/activate
45
+ pip install -r requirements.txt
46
+ python -m detectivesam_inference.predict \
47
+ --checkpoint detective_sam \
48
+ --output-dir outputs/poster_baseline
49
+ ```
50
+
51
+ ## Poster Demo Flows
52
+
53
+ ### 1. Live single-image demo
54
+
55
+ Place your image at `demo/user_image/demo_input.png`, then run:
56
+
57
+ ```bash
58
+ python -m detectivesam_inference.predict \
59
+ --checkpoint detective_sam \
60
+ --output-dir outputs/poster_user_image
61
+ ```
62
+
63
+ In this mode the CLI reuses the target image as its own source reference so the demo stays runnable with a single image.
64
+
65
+ ### 2. Bundled baseline example
66
+
67
+ If `demo/user_image/demo_input.png` is absent, the default `predict` command falls back to the bundled CocoGlide sample `banana_28809`.
68
+
69
+ ```bash
70
+ python -m detectivesam_inference.predict \
71
+ --checkpoint detective_sam \
72
+ --output-dir outputs/poster_baseline
73
+ ```
74
+
75
+ ### 3. Bundled SOTA examples
76
+
77
+ Flux example:
78
+
79
+ ```bash
80
+ python -m detectivesam_inference.predict \
81
+ --checkpoint detective_sam_sota \
82
+ --source demo/flux_test/source/548.png \
83
+ --target demo/flux_test/target/548.png \
84
+ --mask demo/flux_test/mask/548.png \
85
+ --output-dir outputs/poster_flux
86
+ ```
87
+
88
+ Qwen example:
89
+
90
+ ```bash
91
+ python -m detectivesam_inference.predict \
92
+ --checkpoint detective_sam_sota \
93
+ --source demo/qwen_test/source/166.png \
94
+ --target demo/qwen_test/target/166.png \
95
+ --mask demo/qwen_test/mask/166.png \
96
+ --output-dir outputs/poster_qwen
97
+ ```
98
+
99
+ ## Outputs
100
+
101
+ Each `predict` run writes a compact set of visual artifacts plus a JSON summary:
102
+
103
+ - `<name>_comparison.png`
104
+ - `<name>_probability.png`
105
+ - `<name>_pred_mask.png`
106
+ - `<name>_pred_overlay.png`
107
+ - `<name>_summary.json`
108
+
109
+ If a ground-truth mask is provided, the run also saves:
110
+
111
+ - `<name>_gt_mask.png`
112
+ - `<name>_gt_overlay.png`
113
+
114
+ The `evaluate` command writes `summary.json` plus a few visualization examples under `visualizations/`.
115
+
116
+ ## Notes
117
+
118
+ - The runtime selects `cuda` automatically when available and otherwise runs on CPU.
119
+ - Checkpoint settings come from the YAML sidecars in `checkpoints/`; you only need the alias or checkpoint path.
120
+ - This repo does not include training code or training-only dependencies.
121
+ - License metadata is currently marked `other`: the bundled SAM2 components are Apache-2.0, while DetectiveSAM release terms should be finalized before broader redistribution.
checkpoints/detective_sam_sota.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0c7c33c4c4ddb82459bc2b6bbfdfd1003561e2714d02163b3daffb25ede4e1cf
3
+ size 297395157
checkpoints/detective_sam_sota_params.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ img_size: 512
2
+ prompt_dim: 64
3
+ downscale: 16
4
+ dropout_rate: 0.2
5
+ perturbation_type: gaussian_blur/gaussian_noise
6
+ perturbation_intensity: 0.75
7
+ sam_config_file: sam2.1_hiera_b+.yaml
8
+ sam_checkpoint: sam2configs/sam2.1_hiera_base_plus.pt
checkpoints/model_epoch22_batch999_score1.1114.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:898e64f831f6e7e4e8486c0f27b139cbaa44119c449fad2570ab4f6b331ef421
3
+ size 297395157
checkpoints/model_epoch22_batch999_score1.1114_params.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ img_size: 512
2
+ prompt_dim: 64
3
+ downscale: 16
4
+ dropout_rate: 0.15
5
+ perturbation_type: gaussian_blur/gaussian_noise
6
+ perturbation_intensity: 0.75
7
+ sam_config_file: sam2.1_hiera_b+.yaml
8
+ sam_checkpoint: sam2configs/sam2.1_hiera_base_plus.pt
demo/cocoglide/mask/airplane_139871.png ADDED
demo/cocoglide/mask/banana_28809.png ADDED
demo/cocoglide/mask/giraffe_296969.png ADDED
demo/cocoglide/mask/train_221213.png ADDED
demo/cocoglide/mask/tv_453722.png ADDED
demo/cocoglide/source/airplane_139871.png ADDED

Git LFS Details

  • SHA256: 39dc19f9913d883400223b077101128aafae191f97f02507c1ff833768007657
  • Pointer size: 131 Bytes
  • Size of remote file: 111 kB
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/giraffe_296969.png ADDED

Git LFS Details

  • SHA256: 993f0209ab37c289ee6f60d1fa0caf0e77241b5b536f953f3630d7f8ba9fb7cd
  • Pointer size: 131 Bytes
  • Size of remote file: 165 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/source/tv_453722.png ADDED

Git LFS Details

  • SHA256: dc0d7568ec87a8646112d0c5db358a9e77e752ea1df15281adce3e57a4fdf100
  • Pointer size: 131 Bytes
  • Size of remote file: 120 kB
demo/cocoglide/target/airplane_139871.png ADDED

Git LFS Details

  • SHA256: a96ea39f3a1303eec0e6a6a181d2804214149657955a9257443bc659aed64693
  • Pointer size: 131 Bytes
  • Size of remote file: 108 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/giraffe_296969.png ADDED

Git LFS Details

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

Git LFS Details

  • SHA256: 6a427942626ad756626a851d3ed9f8d700e1a43fda02bd33c5c64034b55dcb99
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
demo/cocoglide/target/tv_453722.png ADDED

Git LFS Details

  • SHA256: 292d6ffda682f79caf5a2def64c06d9e35e13337df66863de181c8905186e527
  • Pointer size: 131 Bytes
  • Size of remote file: 121 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,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ DEFAULT_CHECKPOINT = Path("checkpoints/model_epoch22_batch999_score1.1114.pth")
11
+ CHECKPOINT_ALIASES = {
12
+ "detective_sam": DEFAULT_CHECKPOINT,
13
+ "detective_sam_sota": Path("checkpoints/detective_sam_sota.pth"),
14
+ }
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class InferenceConfig:
19
+ img_size: int
20
+ prompt_dim: int
21
+ downscale: int
22
+ dropout_rate: float
23
+ perturbation_type: str
24
+ perturbation_intensity: float
25
+ sam_config_file: str
26
+ sam_checkpoint: str
27
+
28
+ @property
29
+ def max_streams(self) -> int:
30
+ return count_perturbation_streams(self.perturbation_type)
31
+
32
+
33
+ def resolve_checkpoint_path(checkpoint_value: str | Path | None, repo_root: str | Path) -> Path:
34
+ repo_root = Path(repo_root)
35
+ if checkpoint_value is None:
36
+ return repo_root / DEFAULT_CHECKPOINT
37
+
38
+ checkpoint_str = str(checkpoint_value)
39
+ if checkpoint_str in CHECKPOINT_ALIASES:
40
+ return repo_root / CHECKPOINT_ALIASES[checkpoint_str]
41
+
42
+ checkpoint_path = Path(checkpoint_value)
43
+ if checkpoint_path.is_absolute():
44
+ return checkpoint_path
45
+ if checkpoint_path.exists():
46
+ return checkpoint_path.resolve()
47
+
48
+ repo_candidate = repo_root / checkpoint_path
49
+ if repo_candidate.exists():
50
+ return repo_candidate
51
+
52
+ aliased_checkpoint = repo_root / "checkpoints" / f"{checkpoint_str}.pth"
53
+ if aliased_checkpoint.exists():
54
+ return aliased_checkpoint
55
+ return repo_candidate
56
+
57
+
58
+ def resolve_repo_path(path_value: str | Path, repo_root: str | Path) -> Path:
59
+ path = Path(path_value)
60
+ if path.is_absolute():
61
+ return path
62
+
63
+ repo_root = Path(repo_root)
64
+ direct = repo_root / path
65
+ if direct.exists():
66
+ return direct
67
+
68
+ sam_config = repo_root / "sam2configs" / path.name
69
+ if sam_config.exists():
70
+ return sam_config
71
+ return direct
72
+
73
+
74
+ def load_inference_config(checkpoint_path: str | Path) -> InferenceConfig:
75
+ params = _load_params_file(checkpoint_path)
76
+ return InferenceConfig(
77
+ img_size=int(_resolve_param(params, "img_size", section="training_config", default=512)),
78
+ prompt_dim=int(
79
+ _resolve_param(
80
+ params,
81
+ "prompt_dim",
82
+ section="model_config",
83
+ default=_resolve_param(params, "prompt", section="model_config", default=128),
84
+ )
85
+ ),
86
+ downscale=int(_resolve_param(params, "downscale", section="model_config", default=16)),
87
+ dropout_rate=float(
88
+ _resolve_param(
89
+ params,
90
+ "dropout_rate",
91
+ section="model_config",
92
+ default=_resolve_param(params, "dropout", section="model_config", default=0.1),
93
+ )
94
+ ),
95
+ perturbation_type=str(_resolve_param(params, "perturbation_type", section="data_config", default="none")),
96
+ perturbation_intensity=float(
97
+ _resolve_param(params, "perturbation_intensity", section="data_config", default=0.0)
98
+ ),
99
+ sam_config_file=str(
100
+ _resolve_param(params, "sam_config_file", section="sam_config", default="sam2.1_hiera_b+.yaml")
101
+ ),
102
+ sam_checkpoint=str(
103
+ _resolve_param(
104
+ params,
105
+ "sam_checkpoint",
106
+ section="sam_config",
107
+ default="sam2configs/sam2.1_hiera_base_plus.pt",
108
+ )
109
+ ),
110
+ )
111
+
112
+
113
+ def count_perturbation_streams(perturbation_type: str) -> int:
114
+ if perturbation_type == "none":
115
+ return 0
116
+ if "+" in perturbation_type:
117
+ return len(perturbation_type.split("+"))
118
+ if "/" in perturbation_type:
119
+ return len(perturbation_type.split("/"))
120
+ return 1
121
+
122
+
123
+ def _load_params_file(checkpoint_path: str | Path) -> dict[str, Any]:
124
+ checkpoint_path = Path(checkpoint_path)
125
+ candidate_paths = [
126
+ checkpoint_path.with_name(f"{checkpoint_path.stem}_params.yaml"),
127
+ checkpoint_path.parent / "model_params.yaml",
128
+ ]
129
+ for candidate in candidate_paths:
130
+ if candidate.exists():
131
+ with candidate.open("r", encoding="utf-8") as handle:
132
+ loaded = yaml.safe_load(handle)
133
+ if not isinstance(loaded, dict):
134
+ raise ValueError(f"Checkpoint params file must deserialize to a mapping: {candidate}")
135
+ return loaded
136
+ raise FileNotFoundError(
137
+ f"Could not find a params file for checkpoint {checkpoint_path}. "
138
+ f"Checked: {', '.join(str(path) for path in candidate_paths)}"
139
+ )
140
+
141
+
142
+ def _resolve_param(
143
+ params: dict[str, Any],
144
+ key: str,
145
+ *,
146
+ section: str,
147
+ default: Any,
148
+ ) -> Any:
149
+ if key in params:
150
+ return params[key]
151
+ 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",
21
+ help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota.",
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,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ class RefineBlock(nn.Module):
56
+ """Refines the coarse mask with low-level features."""
57
+
58
+ def __init__(
59
+ self,
60
+ hidden_dim: int,
61
+ low_channels: int,
62
+ out_channels: int = 1,
63
+ dropout_rate: float = 0.0,
64
+ ) -> None:
65
+ super().__init__()
66
+ self.conv1 = nn.Conv2d(hidden_dim + low_channels, hidden_dim, kernel_size=3, padding=1)
67
+ self.activation1 = nn.GELU()
68
+ self.dropout1 = nn.Dropout2d(p=dropout_rate)
69
+ self.conv2 = nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1)
70
+ self.activation2 = nn.GELU()
71
+ self.dropout2 = nn.Dropout2d(p=dropout_rate)
72
+ self.conv3 = nn.Conv2d(hidden_dim, out_channels, kernel_size=1)
73
+
74
+ def forward(
75
+ self,
76
+ attention_features: torch.Tensor,
77
+ low_features: torch.Tensor,
78
+ coarse_upsampled: torch.Tensor,
79
+ ) -> torch.Tensor:
80
+ refined = torch.cat([attention_features, low_features], dim=1)
81
+ refined = self.conv1(refined)
82
+ refined = self.activation1(refined)
83
+ refined = self.dropout1(refined)
84
+ refined = self.conv2(refined)
85
+ refined = self.activation2(refined)
86
+ refined = self.dropout2(refined)
87
+ delta = self.conv3(refined)
88
+ return coarse_upsampled + delta
89
+
90
+
91
+ class CoarseProcessingBlock(nn.Module):
92
+ """Adds transformer-based coarse reasoning before refinement."""
93
+
94
+ def __init__(
95
+ self,
96
+ hidden_dim: int,
97
+ attn_dim: int,
98
+ n_heads: int,
99
+ num_encoder_layers: int,
100
+ dropout_rate: float,
101
+ downscale: int,
102
+ ) -> None:
103
+ super().__init__()
104
+ self.hidden_dim = hidden_dim
105
+ self.coarse_down = nn.Sequential(
106
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=downscale, stride=downscale, groups=hidden_dim),
107
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1),
108
+ nn.GELU(),
109
+ nn.Dropout2d(p=dropout_rate),
110
+ )
111
+ self.pos_embed_conv = nn.Conv2d(2, hidden_dim, kernel_size=1)
112
+ self.pos_dropout = nn.Dropout2d(p=dropout_rate)
113
+ self.feat_proj = nn.Sequential(
114
+ nn.Linear(hidden_dim, attn_dim),
115
+ nn.Dropout(p=dropout_rate),
116
+ )
117
+ encoder_layer = nn.TransformerEncoderLayer(
118
+ d_model=attn_dim,
119
+ nhead=n_heads,
120
+ dim_feedforward=attn_dim * 4,
121
+ dropout=dropout_rate,
122
+ activation="gelu",
123
+ batch_first=True,
124
+ )
125
+ self.transformer_encoder = nn.TransformerEncoder(
126
+ encoder_layer,
127
+ num_layers=num_encoder_layers,
128
+ )
129
+ self.transformer_out = nn.Sequential(
130
+ nn.Linear(attn_dim, hidden_dim),
131
+ nn.Dropout(p=dropout_rate),
132
+ )
133
+ self.residual_gate_conv = nn.Sequential(
134
+ nn.Conv2d(hidden_dim * 2, hidden_dim // 4, kernel_size=3, padding=1),
135
+ nn.GELU(),
136
+ nn.Dropout2d(p=dropout_rate),
137
+ nn.Conv2d(hidden_dim // 4, 1, kernel_size=1),
138
+ )
139
+ self.cached_pos_encodings: dict[tuple[int, int], torch.Tensor] = {}
140
+
141
+ def _generate_pos_encoding(self, height: int, width: int) -> torch.Tensor:
142
+ device = self.pos_embed_conv.weight.device
143
+ y_pos = torch.linspace(-1, 1, height, device=device).view(height, 1).expand(height, width)
144
+ x_pos = torch.linspace(-1, 1, width, device=device).view(1, width).expand(height, width)
145
+ pos_grid = torch.stack([y_pos, x_pos], dim=0).unsqueeze(0)
146
+ return self.pos_embed_conv(pos_grid)
147
+
148
+ def _get_positional_encoding(self, batch_size: int, height: int, width: int) -> torch.Tensor:
149
+ key = (height, width)
150
+ device = self.pos_embed_conv.weight.device
151
+ if key not in self.cached_pos_encodings:
152
+ self.cached_pos_encodings[key] = self._generate_pos_encoding(height, width).detach()
153
+
154
+ cached_encoding = self.cached_pos_encodings[key]
155
+ if cached_encoding.device != device:
156
+ cached_encoding = cached_encoding.to(device)
157
+ self.cached_pos_encodings[key] = cached_encoding
158
+ return cached_encoding.expand(batch_size, -1, -1, -1)
159
+
160
+ def forward(self, fused: torch.Tensor) -> torch.Tensor:
161
+ coarse_features = self.coarse_down(fused)
162
+ batch_size, _, height, width = coarse_features.shape
163
+
164
+ pos_embed = self._get_positional_encoding(batch_size, height, width)
165
+ pos_embed = self.pos_dropout(pos_embed)
166
+ coarse_with_position = coarse_features + pos_embed
167
+
168
+ feature_sequence = coarse_with_position.flatten(2).permute(0, 2, 1)
169
+ feature_sequence = self.feat_proj(feature_sequence)
170
+ transformer_output = self.transformer_encoder(feature_sequence)
171
+ hidden = self.transformer_out(transformer_output)
172
+ hidden = hidden.permute(0, 2, 1).view(batch_size, self.hidden_dim, height, width)
173
+
174
+ gate_input = torch.cat([hidden, coarse_features], dim=1)
175
+ residual_gate = torch.sigmoid(self.residual_gate_conv(gate_input))
176
+ return residual_gate * hidden + (1 - residual_gate) * coarse_features
177
+
178
+
179
+ class FineProcessingBlock(nn.Module):
180
+ """Produces the coarse mask and uncertainty map."""
181
+
182
+ def __init__(self, hidden_dim: int, dropout_rate: float) -> None:
183
+ super().__init__()
184
+ self.feature_refinement = nn.Sequential(
185
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
186
+ nn.GELU(),
187
+ nn.Dropout2d(p=dropout_rate),
188
+ nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
189
+ nn.GELU(),
190
+ nn.Dropout2d(p=dropout_rate),
191
+ )
192
+ self.coarse_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
193
+ self.uncertainty_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
194
+
195
+ def forward(
196
+ self,
197
+ hidden: torch.Tensor,
198
+ output_size: tuple[int, int],
199
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
200
+ hidden = self.feature_refinement(hidden)
201
+ coarse_logit = self.coarse_head(hidden)
202
+ uncertainty_logit = self.uncertainty_head(hidden)
203
+
204
+ coarse_mask = F.interpolate(
205
+ coarse_logit,
206
+ size=output_size,
207
+ mode="bilinear",
208
+ align_corners=False,
209
+ )
210
+ uncertainty_map = F.interpolate(
211
+ uncertainty_logit,
212
+ size=output_size,
213
+ mode="bilinear",
214
+ align_corners=False,
215
+ )
216
+ return hidden, coarse_mask, torch.sigmoid(uncertainty_map)
217
+
218
+
219
+ class FeatureFusionBlockSpatial(nn.Module):
220
+ """Fuses original, adapted, and perturbed features with per-pixel attention."""
221
+
222
+ def __init__(
223
+ self,
224
+ in_channels_list: list[int],
225
+ hidden_dim: int = 128,
226
+ dropout_rate: float = 0.1,
227
+ max_streams: int = 2,
228
+ attn_reduction: int = 4,
229
+ ) -> None:
230
+ super().__init__()
231
+ self.num_streams = 2 + max_streams
232
+ self.att_conv = nn.ModuleList()
233
+ self.proj_conv = nn.ModuleList()
234
+
235
+ for channels in in_channels_list:
236
+ total_channels = channels * self.num_streams
237
+ mid_channels = max(total_channels // attn_reduction, 8)
238
+ self.att_conv.append(
239
+ nn.Sequential(
240
+ nn.Conv2d(
241
+ total_channels,
242
+ mid_channels,
243
+ kernel_size=3,
244
+ padding=1,
245
+ groups=self.num_streams,
246
+ bias=False,
247
+ ),
248
+ nn.GELU(),
249
+ nn.Conv2d(mid_channels, self.num_streams, kernel_size=1, bias=False),
250
+ )
251
+ )
252
+ self.proj_conv.append(
253
+ nn.Sequential(
254
+ nn.Conv2d(channels, hidden_dim, kernel_size=1),
255
+ nn.GELU(),
256
+ nn.Dropout2d(p=dropout_rate),
257
+ )
258
+ )
259
+
260
+ fusion_channels = hidden_dim * len(in_channels_list)
261
+ self.fuse_project = nn.Sequential(
262
+ nn.Conv2d(fusion_channels, hidden_dim, kernel_size=1),
263
+ nn.GELU(),
264
+ nn.Dropout2d(p=dropout_rate),
265
+ )
266
+
267
+ def forward(
268
+ self,
269
+ adapted: FeaturePyramid,
270
+ unadapted: FeaturePyramid,
271
+ streams_unadapted: StreamPyramid,
272
+ output_size: tuple[int, int],
273
+ ) -> torch.Tensor:
274
+ fused_scales = []
275
+ for scale_idx, (att_head, projection) in enumerate(zip(self.att_conv, self.proj_conv)):
276
+ streams = [adapted[scale_idx], unadapted[scale_idx], *streams_unadapted[scale_idx]]
277
+ logits = att_head(torch.cat(streams, dim=1))
278
+ weights = F.softmax(logits, dim=1).unsqueeze(2)
279
+ fused = (torch.stack(streams, dim=1) * weights).sum(dim=1)
280
+ fused = projection(fused)
281
+ fused = F.interpolate(
282
+ fused,
283
+ size=output_size,
284
+ mode="bilinear",
285
+ align_corners=False,
286
+ )
287
+ fused_scales.append(fused)
288
+
289
+ return self.fuse_project(torch.cat(fused_scales, dim=1))
290
+
291
+
292
+ class MaskAdapter(nn.Module):
293
+ """Builds the prompt mask passed into the SAM decoder."""
294
+
295
+ def __init__(
296
+ self,
297
+ hidden_dim: int = 256,
298
+ downscale: int = 16,
299
+ output_resolution: tuple[int, int] = (128, 128),
300
+ in_channels_list: list[int] | None = None,
301
+ attn_dim: int = 16,
302
+ n_heads: int = 4,
303
+ num_encoder_layers: int = 2,
304
+ dropout_rate: float = 0.1,
305
+ max_streams: int = 2,
306
+ ) -> None:
307
+ super().__init__()
308
+ channels = in_channels_list or [256, 32, 64]
309
+ self.downscale = downscale
310
+ self.output_resolution = output_resolution
311
+
312
+ self.feature_fusion = FeatureFusionBlockSpatial(
313
+ in_channels_list=channels,
314
+ hidden_dim=hidden_dim,
315
+ dropout_rate=dropout_rate,
316
+ max_streams=max_streams,
317
+ )
318
+ self.coarse_processor = CoarseProcessingBlock(
319
+ hidden_dim=hidden_dim,
320
+ attn_dim=attn_dim,
321
+ n_heads=n_heads,
322
+ num_encoder_layers=num_encoder_layers,
323
+ dropout_rate=dropout_rate,
324
+ downscale=downscale,
325
+ )
326
+ self.fine_processor = FineProcessingBlock(hidden_dim, dropout_rate)
327
+ self.spatial_gate = nn.Sequential(
328
+ nn.Conv2d(2, hidden_dim // 2, kernel_size=3, padding=1),
329
+ nn.GELU(),
330
+ nn.Dropout2d(p=dropout_rate),
331
+ nn.Conv2d(hidden_dim // 2, 1, kernel_size=1),
332
+ nn.Sigmoid(),
333
+ )
334
+ self.refine_head = RefineBlock(
335
+ hidden_dim=hidden_dim,
336
+ low_channels=32,
337
+ out_channels=1,
338
+ dropout_rate=dropout_rate,
339
+ )
340
+
341
+ def forward(
342
+ self,
343
+ adapted: FeaturePyramid,
344
+ streams_unadapted: StreamPyramid,
345
+ unadapted: FeaturePyramid,
346
+ ) -> torch.Tensor:
347
+ output_height, output_width = self.output_resolution
348
+ output_size = (output_height, output_width)
349
+ coarse_size = (output_height // self.downscale, output_width // self.downscale)
350
+
351
+ fused = self.feature_fusion(adapted, unadapted, streams_unadapted, output_size)
352
+ hidden = self.coarse_processor(fused)
353
+ if hidden.shape[-2:] != coarse_size:
354
+ hidden = F.adaptive_avg_pool2d(hidden, coarse_size)
355
+
356
+ hidden, coarse_mask, uncertainty_map = self.fine_processor(hidden, output_size)
357
+ attention_features = F.interpolate(hidden, size=output_size, mode="bilinear", align_corners=False)
358
+ low_features = F.interpolate(unadapted[1], size=output_size, mode="bilinear", align_corners=False)
359
+ refined_mask = self.refine_head(attention_features, low_features, coarse_mask)
360
+
361
+ spatial_gate = self.spatial_gate(torch.cat([coarse_mask, uncertainty_map], dim=1))
362
+ return spatial_gate * refined_mask + (1 - spatial_gate) * coarse_mask
detectivesam_inference/models/forgerylocalizer.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 import MaskAdapter, SharedAdapter
14
+
15
+
16
+ MODEL_CHANNELS = [256, 32, 64]
17
+ MASK_ADAPTER_RESOLUTION = (128, 128)
18
+ FeaturePyramid = list[torch.Tensor]
19
+ StreamPyramid = list[list[torch.Tensor]]
20
+
21
+
22
+ class ForgeryLocalizer(nn.Module):
23
+ """Inference-only DetectiveSAM model."""
24
+
25
+ def __init__(
26
+ self,
27
+ sam_config: str,
28
+ sam_checkpoint: str,
29
+ prompt_dim: int = 256,
30
+ output_resolution: tuple[int, int] = (512, 512),
31
+ downscale: int = 4,
32
+ dropout_rate: float = 0.1,
33
+ max_streams: int = 2,
34
+ device: str = "cpu",
35
+ ) -> None:
36
+ super().__init__()
37
+ self.output_resolution = output_resolution
38
+
39
+ sam = self._build_sam(
40
+ sam_config=sam_config,
41
+ sam_checkpoint=sam_checkpoint,
42
+ output_resolution=output_resolution,
43
+ device=device,
44
+ )
45
+ self.no_mem_embed = sam.no_mem_embed if hasattr(sam, "no_mem_embed") else None
46
+ self.encoder: ImageEncoder = sam.image_encoder
47
+ self.decoder: MaskDecoder = sam.sam_mask_decoder
48
+ self.sam_prompt_encoder: PromptEncoder = sam.sam_prompt_encoder
49
+ self.sam_prompt_encoder.image_embedding_size = MASK_ADAPTER_RESOLUTION
50
+
51
+ self._freeze_sam_components()
52
+ self.adapters = SharedAdapter(
53
+ hidden_dim=prompt_dim,
54
+ in_channels_list=MODEL_CHANNELS,
55
+ dropout_rate=dropout_rate,
56
+ max_streams=max_streams,
57
+ )
58
+ self.mask_adapter = MaskAdapter(
59
+ hidden_dim=prompt_dim,
60
+ output_resolution=MASK_ADAPTER_RESOLUTION,
61
+ downscale=downscale,
62
+ in_channels_list=MODEL_CHANNELS,
63
+ dropout_rate=dropout_rate,
64
+ max_streams=max_streams,
65
+ )
66
+ self.transforms = SAM2Transforms(resolution=sam.image_size, mask_threshold=0.0)
67
+
68
+ @staticmethod
69
+ def _build_sam(
70
+ *,
71
+ sam_config: str,
72
+ sam_checkpoint: str,
73
+ output_resolution: tuple[int, int],
74
+ device: str,
75
+ ) -> SAM2Base:
76
+ sam: SAM2Base = build_sam2(sam_config, sam_checkpoint, device=device)
77
+ sam.image_size = output_resolution[0]
78
+ return sam
79
+
80
+ def _freeze_sam_components(self) -> None:
81
+ for module in (self.encoder, self.decoder, self.sam_prompt_encoder):
82
+ for parameter in module.parameters():
83
+ parameter.requires_grad = False
84
+
85
+ def _project_sam_features(self, backbone_features: FeaturePyramid) -> tuple[torch.Tensor, FeaturePyramid]:
86
+ image_embeddings = backbone_features[-1]
87
+ if self.no_mem_embed is not None:
88
+ image_embeddings = image_embeddings + self.no_mem_embed.reshape(1, 256, 1, 1).detach()
89
+
90
+ high_res_features = [
91
+ self.decoder.conv_s0(backbone_features[0]),
92
+ self.decoder.conv_s1(backbone_features[1]),
93
+ ]
94
+ return image_embeddings, high_res_features
95
+
96
+ def _encode_original_and_streams(
97
+ self,
98
+ orig: torch.Tensor,
99
+ streams: list[torch.Tensor],
100
+ ) -> tuple[FeaturePyramid, StreamPyramid]:
101
+ orig_backbone_features = self.encoder(orig)["backbone_fpn"]
102
+ orig_image_embeddings, orig_high_res_features = self._project_sam_features(orig_backbone_features)
103
+
104
+ stream_image_embeddings: list[torch.Tensor] = []
105
+ stream_high_res_features_0: list[torch.Tensor] = []
106
+ stream_high_res_features_1: list[torch.Tensor] = []
107
+ for stream in streams:
108
+ stream_backbone_features = self.encoder(stream)["backbone_fpn"]
109
+ stream_image_embedding, stream_high_res_features = self._project_sam_features(stream_backbone_features)
110
+ stream_image_embeddings.append(stream_image_embedding)
111
+ stream_high_res_features_0.append(stream_high_res_features[0])
112
+ stream_high_res_features_1.append(stream_high_res_features[1])
113
+
114
+ unadapted = [orig_image_embeddings, orig_high_res_features[0], orig_high_res_features[1]]
115
+ streams_unadapted = [stream_image_embeddings, stream_high_res_features_0, stream_high_res_features_1]
116
+ return unadapted, streams_unadapted
117
+
118
+ def _apply_adapters(
119
+ self,
120
+ unadapted: FeaturePyramid,
121
+ streams_unadapted: StreamPyramid,
122
+ ) -> FeaturePyramid:
123
+ return [
124
+ self.adapters(streams_unadapted[scale_idx], unadapted[scale_idx], scale_idx)
125
+ for scale_idx in range(len(unadapted))
126
+ ]
127
+
128
+ def _prepare_decoder_inputs(
129
+ self,
130
+ adapted: FeaturePyramid,
131
+ mask_prompt: torch.Tensor,
132
+ ) -> tuple[torch.Tensor, FeaturePyramid, torch.Tensor]:
133
+ # Preserve the original interpolation steps so inference stays bit-exact.
134
+ image_embeddings = F.interpolate(
135
+ adapted[0],
136
+ size=adapted[0].shape[-2:],
137
+ mode="bilinear",
138
+ align_corners=False,
139
+ )
140
+ high_res_features = [
141
+ F.interpolate(
142
+ feature,
143
+ size=feature.shape[-2:],
144
+ mode="bilinear",
145
+ align_corners=False,
146
+ )
147
+ for feature in adapted[1:]
148
+ ]
149
+ mask_prompt = F.interpolate(
150
+ mask_prompt,
151
+ size=mask_prompt.shape[-2:],
152
+ mode="nearest",
153
+ )
154
+ return image_embeddings, high_res_features, mask_prompt
155
+
156
+ def forward(self, orig: torch.Tensor, streams: list[torch.Tensor]) -> torch.Tensor:
157
+ unadapted, streams_unadapted = self._encode_original_and_streams(orig, streams)
158
+ adapted = self._apply_adapters(unadapted, streams_unadapted)
159
+
160
+ mask_prompt = self.mask_adapter(adapted, streams_unadapted, unadapted)
161
+ image_embeddings, high_res_features, mask_prompt = self._prepare_decoder_inputs(adapted, mask_prompt)
162
+
163
+ self.sam_prompt_encoder.image_embedding_size = image_embeddings.shape[-2:]
164
+ sparse_prompt_embeddings, dense_prompt_embeddings = self.sam_prompt_encoder(
165
+ points=None,
166
+ boxes=None,
167
+ masks=mask_prompt,
168
+ )
169
+
170
+ mask_logits, _, _, _ = self.decoder(
171
+ image_embeddings=image_embeddings,
172
+ image_pe=self.sam_prompt_encoder.get_dense_pe(),
173
+ sparse_prompt_embeddings=sparse_prompt_embeddings,
174
+ dense_prompt_embeddings=dense_prompt_embeddings,
175
+ multimask_output=False,
176
+ repeat_image=False,
177
+ high_res_features=high_res_features,
178
+ )
179
+ return self.transforms.postprocess_masks(mask_logits, torch.Size(self.output_resolution))
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",
33
+ help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota.",
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,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ device=str(self.device),
75
+ ).to(self.device)
76
+
77
+ checkpoint = torch.load(self.checkpoint_path, map_location=self.device, weights_only=False)
78
+ state_dict = checkpoint["model"] if isinstance(checkpoint, dict) and "model" in checkpoint else checkpoint
79
+ model.load_state_dict(state_dict)
80
+ model.eval()
81
+ return model
82
+
83
+ def autocast_context(self):
84
+ if self.device.type == "cuda":
85
+ return torch.amp.autocast(device_type="cuda")
86
+ return nullcontext()
87
+
88
+ def predict_sample(
89
+ self,
90
+ sample: PreparedSample,
91
+ threshold: float = 0.5,
92
+ ) -> PredictionResult:
93
+ orig = sample.orig.unsqueeze(0).to(self.device)
94
+ streams = [stream.unsqueeze(0).to(self.device) for stream in sample.streams]
95
+
96
+ with torch.inference_mode():
97
+ with self.autocast_context():
98
+ logits = self.model(orig, streams)
99
+
100
+ probability = torch.sigmoid(logits).squeeze().detach().cpu().numpy()
101
+ pred_mask = (probability > threshold).astype("uint8")
102
+ 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,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.4
2
+ torchvision>=0.19
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
9
+
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
sam2configs/sam2.1_hiera_base_plus.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a2345aede8715ab1d5d31b4a509fb160c5a4af1970f199d9054ccfb746c004c5
3
+ size 323606802
tests/test_regression.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 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.runtime import DetectiveSAMRunner, get_repo_root
12
+
13
+
14
+ def assert_exact(value: float | None, expected: float | None, *, abs_tol: float = 1e-12) -> None:
15
+ assert value is not None
16
+ assert expected is not None
17
+ assert math.isclose(value, expected, rel_tol=0.0, abs_tol=abs_tol)
18
+
19
+
20
+ @pytest.fixture(scope="module")
21
+ def repo_root() -> Path:
22
+ return get_repo_root()
23
+
24
+
25
+ @pytest.fixture(scope="module")
26
+ def baseline_runner() -> DetectiveSAMRunner:
27
+ return DetectiveSAMRunner(checkpoint_path="detective_sam", device="cpu")
28
+
29
+
30
+ @pytest.fixture(scope="module")
31
+ def sota_runner() -> DetectiveSAMRunner:
32
+ return DetectiveSAMRunner(checkpoint_path="detective_sam_sota", device="cpu")
33
+
34
+
35
+ def predict_metrics(
36
+ runner: DetectiveSAMRunner,
37
+ *,
38
+ source_path: Path,
39
+ target_path: Path,
40
+ mask_path: Path,
41
+ ) -> tuple[float, float]:
42
+ sample = prepare_sample(
43
+ source_path=source_path,
44
+ target_path=target_path,
45
+ mask_path=mask_path,
46
+ img_size=runner.config.img_size,
47
+ perturbation_type=runner.config.perturbation_type,
48
+ perturbation_intensity=runner.config.perturbation_intensity,
49
+ )
50
+ prediction = runner.predict_sample(sample, threshold=0.5)
51
+ true_mask = sample.mask.squeeze().numpy().astype("uint8")
52
+ return compute_iou(prediction.pred_mask, true_mask), compute_f1(prediction.pred_mask, true_mask)
53
+
54
+
55
+ def test_checkpoint_alias_resolution(repo_root: Path) -> None:
56
+ assert resolve_checkpoint_path("detective_sam", repo_root) == repo_root / "checkpoints" / "model_epoch22_batch999_score1.1114.pth"
57
+ assert resolve_checkpoint_path("detective_sam_sota", repo_root) == repo_root / "checkpoints" / "detective_sam_sota.pth"
58
+
59
+
60
+ def test_baseline_banana_demo_metrics(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
61
+ demo_root = repo_root / "demo" / "cocoglide"
62
+ iou, f1 = predict_metrics(
63
+ baseline_runner,
64
+ source_path=demo_root / "source" / "banana_28809.png",
65
+ target_path=demo_root / "target" / "banana_28809.png",
66
+ mask_path=demo_root / "mask" / "banana_28809.png",
67
+ )
68
+ assert_exact(iou, 0.8566427949370513)
69
+ assert_exact(f1, 0.9227868680750683)
70
+
71
+
72
+ def test_sota_flux_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
73
+ demo_root = repo_root / "demo" / "flux_test"
74
+ iou, f1 = predict_metrics(
75
+ sota_runner,
76
+ source_path=demo_root / "source" / "548.png",
77
+ target_path=demo_root / "target" / "548.png",
78
+ mask_path=demo_root / "mask" / "548.png",
79
+ )
80
+ assert_exact(iou, 0.8703024868799283)
81
+ assert_exact(f1, 0.9306542583192329)
82
+
83
+
84
+ def test_sota_qwen_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
85
+ demo_root = repo_root / "demo" / "qwen_test"
86
+ iou, f1 = predict_metrics(
87
+ sota_runner,
88
+ source_path=demo_root / "source" / "166.png",
89
+ target_path=demo_root / "target" / "166.png",
90
+ mask_path=demo_root / "mask" / "166.png",
91
+ )
92
+ assert_exact(iou, 0.8297306693388413)
93
+ assert_exact(f1, 0.9069429542203147)
94
+
95
+
96
+ def test_baseline_cocoglide_eval_summary(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
97
+ dataset = PairDataset(
98
+ root_dir=repo_root / "demo" / "cocoglide",
99
+ img_size=baseline_runner.config.img_size,
100
+ perturbation_type=baseline_runner.config.perturbation_type,
101
+ perturbation_intensity=baseline_runner.config.perturbation_intensity,
102
+ )
103
+
104
+ per_sample_results: list[dict[str, float | str | None]] = []
105
+ for sample in dataset:
106
+ prediction = baseline_runner.predict_sample(sample, threshold=0.5)
107
+ true_mask = sample.mask.squeeze().numpy().astype("uint8")
108
+ per_sample_results.append(
109
+ {
110
+ "name": sample.name,
111
+ "iou": compute_iou(prediction.pred_mask, true_mask),
112
+ "f1": compute_f1(prediction.pred_mask, true_mask),
113
+ }
114
+ )
115
+
116
+ summary = summarize_results(per_sample_results)
117
+ assert summary["num_samples"] == 5
118
+ assert summary["num_samples_with_gt"] == 5
119
+ assert_exact(summary["mean_iou"], 0.5092573070481035)
120
+ assert_exact(summary["mean_f1"], 0.6509390858765342)
121
+
122
+ expected_by_name = {
123
+ "airplane_139871": (0.41829717560376584, 0.5898582931686339),
124
+ "banana_28809": (0.8566427949370513, 0.9227868680750683),
125
+ "giraffe_296969": (0.22833093957714018, 0.3717743031951054),
126
+ "train_221213": (0.547253866814856, 0.7073872989458688),
127
+ "tv_453722": (0.49576175830770386, 0.662888665997994),
128
+ }
129
+ for result in per_sample_results:
130
+ expected_iou, expected_f1 = expected_by_name[result["name"]]
131
+ assert_exact(result["iou"], expected_iou)
132
+ assert_exact(result["f1"], expected_f1)