Upload clean DetectiveSAMv2 inference bundle
Browse files- .gitattributes +10 -35
- .gitignore +9 -0
- README.md +177 -0
- checkpoints/README.md +13 -0
- checkpoints/detective_sam_v2_params.yaml +10 -0
- demo/cocoglide/mask/banana_28809.png +0 -0
- demo/cocoglide/mask/train_221213.png +0 -0
- demo/cocoglide/source/banana_28809.png +3 -0
- demo/cocoglide/source/train_221213.png +3 -0
- demo/cocoglide/target/banana_28809.png +3 -0
- demo/cocoglide/target/train_221213.png +3 -0
- demo/flux_test/mask/548.png +0 -0
- demo/flux_test/source/548.png +3 -0
- demo/flux_test/target/548.png +3 -0
- demo/qwen_test/mask/166.png +0 -0
- demo/qwen_test/source/166.png +3 -0
- demo/qwen_test/target/166.png +3 -0
- demo/user_image/README.md +14 -0
- detectivesam_inference/__init__.py +4 -0
- detectivesam_inference/checkpoint.py +161 -0
- detectivesam_inference/dataset.py +249 -0
- detectivesam_inference/evaluate.py +86 -0
- detectivesam_inference/metrics.py +50 -0
- detectivesam_inference/models/__init__.py +3 -0
- detectivesam_inference/models/adapters.py +37 -0
- detectivesam_inference/models/adapters_v1.py +366 -0
- detectivesam_inference/models/adapters_v2.py +651 -0
- detectivesam_inference/models/forgerylocalizer.py +305 -0
- detectivesam_inference/perturbations.py +71 -0
- detectivesam_inference/predict.py +113 -0
- detectivesam_inference/runtime.py +138 -0
- detectivesam_inference/visualization.py +82 -0
- pytest.ini +2 -0
- requirements.txt +8 -0
- sam2configs/README.md +13 -0
- sam2configs/sam2.1_hiera_b+.yaml +116 -0
- tests/test_regression.py +189 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,10 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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
|
demo/cocoglide/source/train_221213.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/banana_28809.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/train_221213.png
ADDED
|
Git LFS Details
|
demo/flux_test/mask/548.png
ADDED
|
demo/flux_test/source/548.png
ADDED
|
Git LFS Details
|
demo/flux_test/target/548.png
ADDED
|
Git LFS Details
|
demo/qwen_test/mask/166.png
ADDED
|
demo/qwen_test/source/166.png
ADDED
|
Git LFS Details
|
demo/qwen_test/target/166.png
ADDED
|
Git LFS Details
|
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)
|