Instructions to use Gertlek/DetectiveSAM with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sam2
How to use Gertlek/DetectiveSAM with sam2:
# Use SAM2 with images import torch from sam2.sam2_image_predictor import SAM2ImagePredictor predictor = SAM2ImagePredictor.from_pretrained(Gertlek/DetectiveSAM) with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): predictor.set_image(<your_image>) masks, _, _ = predictor.predict(<input_prompts>)# Use SAM2 with videos import torch from sam2.sam2_video_predictor import SAM2VideoPredictor predictor = SAM2VideoPredictor.from_pretrained(Gertlek/DetectiveSAM) with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): state = predictor.init_state(<your_video>) # add new prompts and instantly get the output on the same frame frame_idx, object_ids, masks = predictor.add_new_points(state, <your_prompts>): # propagate the prompts to get masklets throughout the video for frame_idx, object_ids, masks in predictor.propagate_in_video(state): ... - Notebooks
- Google Colab
- Kaggle
Publish DetectiveSAM inference bundle
Browse files- .gitattributes +16 -35
- .gitignore +7 -0
- README.md +121 -0
- checkpoints/detective_sam_sota.pth +3 -0
- checkpoints/detective_sam_sota_params.yaml +8 -0
- checkpoints/model_epoch22_batch999_score1.1114.pth +3 -0
- checkpoints/model_epoch22_batch999_score1.1114_params.yaml +8 -0
- demo/cocoglide/mask/airplane_139871.png +0 -0
- demo/cocoglide/mask/banana_28809.png +0 -0
- demo/cocoglide/mask/giraffe_296969.png +0 -0
- demo/cocoglide/mask/train_221213.png +0 -0
- demo/cocoglide/mask/tv_453722.png +0 -0
- demo/cocoglide/source/airplane_139871.png +3 -0
- demo/cocoglide/source/banana_28809.png +3 -0
- demo/cocoglide/source/giraffe_296969.png +3 -0
- demo/cocoglide/source/train_221213.png +3 -0
- demo/cocoglide/source/tv_453722.png +3 -0
- demo/cocoglide/target/airplane_139871.png +3 -0
- demo/cocoglide/target/banana_28809.png +3 -0
- demo/cocoglide/target/giraffe_296969.png +3 -0
- demo/cocoglide/target/train_221213.png +3 -0
- demo/cocoglide/target/tv_453722.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 +151 -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 +362 -0
- detectivesam_inference/models/forgerylocalizer.py +179 -0
- detectivesam_inference/perturbations.py +71 -0
- detectivesam_inference/predict.py +113 -0
- detectivesam_inference/runtime.py +102 -0
- detectivesam_inference/visualization.py +82 -0
- pytest.ini +2 -0
- requirements.txt +9 -0
- sam2configs/sam2.1_hiera_b+.yaml +116 -0
- sam2configs/sam2.1_hiera_base_plus.pt +3 -0
- tests/test_regression.py +132 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,16 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
sam2configs/*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
checkpoints/*.pth filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
demo/cocoglide/source/airplane_139871.png filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
demo/cocoglide/source/banana_28809.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
demo/cocoglide/source/giraffe_296969.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
demo/cocoglide/source/train_221213.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
demo/cocoglide/source/tv_453722.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
demo/cocoglide/target/airplane_139871.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
demo/cocoglide/target/banana_28809.png filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
demo/cocoglide/target/giraffe_296969.png filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
demo/cocoglide/target/train_221213.png filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
demo/cocoglide/target/tv_453722.png filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
demo/flux_test/source/548.png filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
demo/flux_test/target/548.png filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
demo/qwen_test/source/166.png filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
demo/qwen_test/target/166.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.DS_Store
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
outputs/
|
README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: other
|
| 3 |
+
library_name: pytorch
|
| 4 |
+
pipeline_tag: image-segmentation
|
| 5 |
+
tags:
|
| 6 |
+
- image-forensics
|
| 7 |
+
- image-manipulation-detection
|
| 8 |
+
- image-segmentation
|
| 9 |
+
- sam2
|
| 10 |
+
- pytorch
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# DetectiveSAM
|
| 14 |
+
|
| 15 |
+
DetectiveSAM is an inference-only image forgery localization bundle built around SAM2. This release includes bundled checkpoints and a small set of ready-to-run examples for demos.
|
| 16 |
+
|
| 17 |
+
## What is bundled
|
| 18 |
+
|
| 19 |
+
- Inference checkpoints under `checkpoints/`
|
| 20 |
+
- SAM2 config and weights under `sam2configs/`
|
| 21 |
+
- Poster demo pairs under `demo/cocoglide/`, `demo/flux_test/`, and `demo/qwen_test/`
|
| 22 |
+
- A drop-in single-image slot at `demo/user_image/demo_input.png`
|
| 23 |
+
|
| 24 |
+
Built-in checkpoint aliases:
|
| 25 |
+
|
| 26 |
+
- `detective_sam`
|
| 27 |
+
- `detective_sam_sota`
|
| 28 |
+
|
| 29 |
+
## Setup
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
python -m venv .venv
|
| 33 |
+
source .venv/bin/activate
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## Hugging Face Usage
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
git lfs install
|
| 41 |
+
git clone https://huggingface.co/Gertlek/DetectiveSAM
|
| 42 |
+
cd DetectiveSAM
|
| 43 |
+
python -m venv .venv
|
| 44 |
+
source .venv/bin/activate
|
| 45 |
+
pip install -r requirements.txt
|
| 46 |
+
python -m detectivesam_inference.predict \
|
| 47 |
+
--checkpoint detective_sam \
|
| 48 |
+
--output-dir outputs/poster_baseline
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Poster Demo Flows
|
| 52 |
+
|
| 53 |
+
### 1. Live single-image demo
|
| 54 |
+
|
| 55 |
+
Place your image at `demo/user_image/demo_input.png`, then run:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
python -m detectivesam_inference.predict \
|
| 59 |
+
--checkpoint detective_sam \
|
| 60 |
+
--output-dir outputs/poster_user_image
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
In this mode the CLI reuses the target image as its own source reference so the demo stays runnable with a single image.
|
| 64 |
+
|
| 65 |
+
### 2. Bundled baseline example
|
| 66 |
+
|
| 67 |
+
If `demo/user_image/demo_input.png` is absent, the default `predict` command falls back to the bundled CocoGlide sample `banana_28809`.
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python -m detectivesam_inference.predict \
|
| 71 |
+
--checkpoint detective_sam \
|
| 72 |
+
--output-dir outputs/poster_baseline
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 3. Bundled SOTA examples
|
| 76 |
+
|
| 77 |
+
Flux example:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
python -m detectivesam_inference.predict \
|
| 81 |
+
--checkpoint detective_sam_sota \
|
| 82 |
+
--source demo/flux_test/source/548.png \
|
| 83 |
+
--target demo/flux_test/target/548.png \
|
| 84 |
+
--mask demo/flux_test/mask/548.png \
|
| 85 |
+
--output-dir outputs/poster_flux
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
Qwen example:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
python -m detectivesam_inference.predict \
|
| 92 |
+
--checkpoint detective_sam_sota \
|
| 93 |
+
--source demo/qwen_test/source/166.png \
|
| 94 |
+
--target demo/qwen_test/target/166.png \
|
| 95 |
+
--mask demo/qwen_test/mask/166.png \
|
| 96 |
+
--output-dir outputs/poster_qwen
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
## Outputs
|
| 100 |
+
|
| 101 |
+
Each `predict` run writes a compact set of visual artifacts plus a JSON summary:
|
| 102 |
+
|
| 103 |
+
- `<name>_comparison.png`
|
| 104 |
+
- `<name>_probability.png`
|
| 105 |
+
- `<name>_pred_mask.png`
|
| 106 |
+
- `<name>_pred_overlay.png`
|
| 107 |
+
- `<name>_summary.json`
|
| 108 |
+
|
| 109 |
+
If a ground-truth mask is provided, the run also saves:
|
| 110 |
+
|
| 111 |
+
- `<name>_gt_mask.png`
|
| 112 |
+
- `<name>_gt_overlay.png`
|
| 113 |
+
|
| 114 |
+
The `evaluate` command writes `summary.json` plus a few visualization examples under `visualizations/`.
|
| 115 |
+
|
| 116 |
+
## Notes
|
| 117 |
+
|
| 118 |
+
- The runtime selects `cuda` automatically when available and otherwise runs on CPU.
|
| 119 |
+
- Checkpoint settings come from the YAML sidecars in `checkpoints/`; you only need the alias or checkpoint path.
|
| 120 |
+
- This repo does not include training code or training-only dependencies.
|
| 121 |
+
- License metadata is currently marked `other`: the bundled SAM2 components are Apache-2.0, while DetectiveSAM release terms should be finalized before broader redistribution.
|
checkpoints/detective_sam_sota.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c7c33c4c4ddb82459bc2b6bbfdfd1003561e2714d02163b3daffb25ede4e1cf
|
| 3 |
+
size 297395157
|
checkpoints/detective_sam_sota_params.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
img_size: 512
|
| 2 |
+
prompt_dim: 64
|
| 3 |
+
downscale: 16
|
| 4 |
+
dropout_rate: 0.2
|
| 5 |
+
perturbation_type: gaussian_blur/gaussian_noise
|
| 6 |
+
perturbation_intensity: 0.75
|
| 7 |
+
sam_config_file: sam2.1_hiera_b+.yaml
|
| 8 |
+
sam_checkpoint: sam2configs/sam2.1_hiera_base_plus.pt
|
checkpoints/model_epoch22_batch999_score1.1114.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:898e64f831f6e7e4e8486c0f27b139cbaa44119c449fad2570ab4f6b331ef421
|
| 3 |
+
size 297395157
|
checkpoints/model_epoch22_batch999_score1.1114_params.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
img_size: 512
|
| 2 |
+
prompt_dim: 64
|
| 3 |
+
downscale: 16
|
| 4 |
+
dropout_rate: 0.15
|
| 5 |
+
perturbation_type: gaussian_blur/gaussian_noise
|
| 6 |
+
perturbation_intensity: 0.75
|
| 7 |
+
sam_config_file: sam2.1_hiera_b+.yaml
|
| 8 |
+
sam_checkpoint: sam2configs/sam2.1_hiera_base_plus.pt
|
demo/cocoglide/mask/airplane_139871.png
ADDED
|
demo/cocoglide/mask/banana_28809.png
ADDED
|
demo/cocoglide/mask/giraffe_296969.png
ADDED
|
demo/cocoglide/mask/train_221213.png
ADDED
|
demo/cocoglide/mask/tv_453722.png
ADDED
|
demo/cocoglide/source/airplane_139871.png
ADDED
|
Git LFS Details
|
demo/cocoglide/source/banana_28809.png
ADDED
|
Git LFS Details
|
demo/cocoglide/source/giraffe_296969.png
ADDED
|
Git LFS Details
|
demo/cocoglide/source/train_221213.png
ADDED
|
Git LFS Details
|
demo/cocoglide/source/tv_453722.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/airplane_139871.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/banana_28809.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/giraffe_296969.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/train_221213.png
ADDED
|
Git LFS Details
|
demo/cocoglide/target/tv_453722.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,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
DEFAULT_CHECKPOINT = Path("checkpoints/model_epoch22_batch999_score1.1114.pth")
|
| 11 |
+
CHECKPOINT_ALIASES = {
|
| 12 |
+
"detective_sam": DEFAULT_CHECKPOINT,
|
| 13 |
+
"detective_sam_sota": Path("checkpoints/detective_sam_sota.pth"),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class InferenceConfig:
|
| 19 |
+
img_size: int
|
| 20 |
+
prompt_dim: int
|
| 21 |
+
downscale: int
|
| 22 |
+
dropout_rate: float
|
| 23 |
+
perturbation_type: str
|
| 24 |
+
perturbation_intensity: float
|
| 25 |
+
sam_config_file: str
|
| 26 |
+
sam_checkpoint: str
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def max_streams(self) -> int:
|
| 30 |
+
return count_perturbation_streams(self.perturbation_type)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def resolve_checkpoint_path(checkpoint_value: str | Path | None, repo_root: str | Path) -> Path:
|
| 34 |
+
repo_root = Path(repo_root)
|
| 35 |
+
if checkpoint_value is None:
|
| 36 |
+
return repo_root / DEFAULT_CHECKPOINT
|
| 37 |
+
|
| 38 |
+
checkpoint_str = str(checkpoint_value)
|
| 39 |
+
if checkpoint_str in CHECKPOINT_ALIASES:
|
| 40 |
+
return repo_root / CHECKPOINT_ALIASES[checkpoint_str]
|
| 41 |
+
|
| 42 |
+
checkpoint_path = Path(checkpoint_value)
|
| 43 |
+
if checkpoint_path.is_absolute():
|
| 44 |
+
return checkpoint_path
|
| 45 |
+
if checkpoint_path.exists():
|
| 46 |
+
return checkpoint_path.resolve()
|
| 47 |
+
|
| 48 |
+
repo_candidate = repo_root / checkpoint_path
|
| 49 |
+
if repo_candidate.exists():
|
| 50 |
+
return repo_candidate
|
| 51 |
+
|
| 52 |
+
aliased_checkpoint = repo_root / "checkpoints" / f"{checkpoint_str}.pth"
|
| 53 |
+
if aliased_checkpoint.exists():
|
| 54 |
+
return aliased_checkpoint
|
| 55 |
+
return repo_candidate
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def resolve_repo_path(path_value: str | Path, repo_root: str | Path) -> Path:
|
| 59 |
+
path = Path(path_value)
|
| 60 |
+
if path.is_absolute():
|
| 61 |
+
return path
|
| 62 |
+
|
| 63 |
+
repo_root = Path(repo_root)
|
| 64 |
+
direct = repo_root / path
|
| 65 |
+
if direct.exists():
|
| 66 |
+
return direct
|
| 67 |
+
|
| 68 |
+
sam_config = repo_root / "sam2configs" / path.name
|
| 69 |
+
if sam_config.exists():
|
| 70 |
+
return sam_config
|
| 71 |
+
return direct
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_inference_config(checkpoint_path: str | Path) -> InferenceConfig:
|
| 75 |
+
params = _load_params_file(checkpoint_path)
|
| 76 |
+
return InferenceConfig(
|
| 77 |
+
img_size=int(_resolve_param(params, "img_size", section="training_config", default=512)),
|
| 78 |
+
prompt_dim=int(
|
| 79 |
+
_resolve_param(
|
| 80 |
+
params,
|
| 81 |
+
"prompt_dim",
|
| 82 |
+
section="model_config",
|
| 83 |
+
default=_resolve_param(params, "prompt", section="model_config", default=128),
|
| 84 |
+
)
|
| 85 |
+
),
|
| 86 |
+
downscale=int(_resolve_param(params, "downscale", section="model_config", default=16)),
|
| 87 |
+
dropout_rate=float(
|
| 88 |
+
_resolve_param(
|
| 89 |
+
params,
|
| 90 |
+
"dropout_rate",
|
| 91 |
+
section="model_config",
|
| 92 |
+
default=_resolve_param(params, "dropout", section="model_config", default=0.1),
|
| 93 |
+
)
|
| 94 |
+
),
|
| 95 |
+
perturbation_type=str(_resolve_param(params, "perturbation_type", section="data_config", default="none")),
|
| 96 |
+
perturbation_intensity=float(
|
| 97 |
+
_resolve_param(params, "perturbation_intensity", section="data_config", default=0.0)
|
| 98 |
+
),
|
| 99 |
+
sam_config_file=str(
|
| 100 |
+
_resolve_param(params, "sam_config_file", section="sam_config", default="sam2.1_hiera_b+.yaml")
|
| 101 |
+
),
|
| 102 |
+
sam_checkpoint=str(
|
| 103 |
+
_resolve_param(
|
| 104 |
+
params,
|
| 105 |
+
"sam_checkpoint",
|
| 106 |
+
section="sam_config",
|
| 107 |
+
default="sam2configs/sam2.1_hiera_base_plus.pt",
|
| 108 |
+
)
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def count_perturbation_streams(perturbation_type: str) -> int:
|
| 114 |
+
if perturbation_type == "none":
|
| 115 |
+
return 0
|
| 116 |
+
if "+" in perturbation_type:
|
| 117 |
+
return len(perturbation_type.split("+"))
|
| 118 |
+
if "/" in perturbation_type:
|
| 119 |
+
return len(perturbation_type.split("/"))
|
| 120 |
+
return 1
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _load_params_file(checkpoint_path: str | Path) -> dict[str, Any]:
|
| 124 |
+
checkpoint_path = Path(checkpoint_path)
|
| 125 |
+
candidate_paths = [
|
| 126 |
+
checkpoint_path.with_name(f"{checkpoint_path.stem}_params.yaml"),
|
| 127 |
+
checkpoint_path.parent / "model_params.yaml",
|
| 128 |
+
]
|
| 129 |
+
for candidate in candidate_paths:
|
| 130 |
+
if candidate.exists():
|
| 131 |
+
with candidate.open("r", encoding="utf-8") as handle:
|
| 132 |
+
loaded = yaml.safe_load(handle)
|
| 133 |
+
if not isinstance(loaded, dict):
|
| 134 |
+
raise ValueError(f"Checkpoint params file must deserialize to a mapping: {candidate}")
|
| 135 |
+
return loaded
|
| 136 |
+
raise FileNotFoundError(
|
| 137 |
+
f"Could not find a params file for checkpoint {checkpoint_path}. "
|
| 138 |
+
f"Checked: {', '.join(str(path) for path in candidate_paths)}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _resolve_param(
|
| 143 |
+
params: dict[str, Any],
|
| 144 |
+
key: str,
|
| 145 |
+
*,
|
| 146 |
+
section: str,
|
| 147 |
+
default: Any,
|
| 148 |
+
) -> Any:
|
| 149 |
+
if key in params:
|
| 150 |
+
return params[key]
|
| 151 |
+
return params.get(section, {}).get(key, default)
|
detectivesam_inference/dataset.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torchvision.transforms.functional as TF
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from sam2.utils.transforms import SAM2Transforms
|
| 13 |
+
from torch.utils.data import Dataset
|
| 14 |
+
|
| 15 |
+
from detectivesam_inference.perturbations import (
|
| 16 |
+
add_gaussian_noise_deterministic,
|
| 17 |
+
apply_blur_to_image_tensor,
|
| 18 |
+
apply_jpeg_compression_to_tensor,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
LEGACY_CONTRASTIVE_FLAG = False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class PreparedSample:
|
| 27 |
+
name: str
|
| 28 |
+
source_path: Path
|
| 29 |
+
target_path: Path
|
| 30 |
+
mask_path: Path | None
|
| 31 |
+
source_image: Image.Image
|
| 32 |
+
target_image: Image.Image
|
| 33 |
+
orig: torch.Tensor
|
| 34 |
+
streams: list[torch.Tensor]
|
| 35 |
+
mask: torch.Tensor | None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_perturbation_types(perturbation_type: str) -> list[str]:
|
| 39 |
+
if perturbation_type == "none":
|
| 40 |
+
return []
|
| 41 |
+
if "+" in perturbation_type:
|
| 42 |
+
return [item.strip() for item in perturbation_type.split("+")]
|
| 43 |
+
if "/" in perturbation_type:
|
| 44 |
+
return [item.strip() for item in perturbation_type.split("/")]
|
| 45 |
+
return [perturbation_type.strip()]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def compute_perturbation_params(perturbation_intensity: float) -> dict[str, float | int]:
|
| 49 |
+
return {
|
| 50 |
+
"blur_sigma": perturbation_intensity * 2.0,
|
| 51 |
+
"jpeg_quality": max(10, int(95 - (perturbation_intensity * 56.67))),
|
| 52 |
+
"noise_std": perturbation_intensity * 0.2,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def create_combined_mask(
|
| 57 |
+
mask_rgba: np.ndarray,
|
| 58 |
+
) -> np.ndarray:
|
| 59 |
+
if mask_rgba.ndim == 2:
|
| 60 |
+
return (mask_rgba // 255).astype(np.uint8)
|
| 61 |
+
|
| 62 |
+
if mask_rgba.ndim == 3 and mask_rgba.shape[2] == 4:
|
| 63 |
+
alpha = mask_rgba[:, :, 3]
|
| 64 |
+
alpha_is_opaque = alpha.sum() == alpha.size * 255
|
| 65 |
+
if alpha_is_opaque:
|
| 66 |
+
foreground = (mask_rgba[:, :, 0] > 0).astype(np.uint8)
|
| 67 |
+
return cv2.resize(foreground, (512, 512), interpolation=cv2.INTER_NEAREST).astype(np.uint8)
|
| 68 |
+
|
| 69 |
+
_, binary = cv2.threshold(alpha, 0, 255, cv2.THRESH_BINARY)
|
| 70 |
+
return (1 - (binary // 255)).astype(np.uint8)
|
| 71 |
+
|
| 72 |
+
return (mask_rgba[:, :, 0] > 0).astype(np.uint8)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def resize_triplet(
|
| 76 |
+
source_image: Image.Image,
|
| 77 |
+
target_image: Image.Image,
|
| 78 |
+
mask_image: Image.Image | None,
|
| 79 |
+
img_size: int,
|
| 80 |
+
) -> tuple[Image.Image, Image.Image, Image.Image | None]:
|
| 81 |
+
source_resized = source_image.resize((img_size, img_size), Image.BILINEAR)
|
| 82 |
+
target_resized = target_image.resize((img_size, img_size), Image.BILINEAR)
|
| 83 |
+
if mask_image is None:
|
| 84 |
+
return source_resized, target_resized, None
|
| 85 |
+
return source_resized, target_resized, mask_image.resize((img_size, img_size), Image.NEAREST)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def build_streams(
|
| 89 |
+
target_image: Image.Image,
|
| 90 |
+
perturbation_type: str,
|
| 91 |
+
perturbation_intensity: float,
|
| 92 |
+
seed: int,
|
| 93 |
+
) -> list[torch.Tensor]:
|
| 94 |
+
perturbations = parse_perturbation_types(perturbation_type)
|
| 95 |
+
params = compute_perturbation_params(perturbation_intensity)
|
| 96 |
+
orig_tensor = TF.to_tensor(target_image)
|
| 97 |
+
|
| 98 |
+
streams: list[torch.Tensor] = []
|
| 99 |
+
for perturbation in perturbations:
|
| 100 |
+
if perturbation == "gaussian_blur":
|
| 101 |
+
streams.append(apply_blur_to_image_tensor(orig_tensor, sigma=float(params["blur_sigma"])))
|
| 102 |
+
elif perturbation == "jpeg_compression":
|
| 103 |
+
streams.append(apply_jpeg_compression_to_tensor(orig_tensor, quality=int(params["jpeg_quality"])))
|
| 104 |
+
elif perturbation == "gaussian_noise":
|
| 105 |
+
streams.append(
|
| 106 |
+
add_gaussian_noise_deterministic(
|
| 107 |
+
orig_tensor,
|
| 108 |
+
std=float(params["noise_std"]),
|
| 109 |
+
seed=seed + len(streams),
|
| 110 |
+
)
|
| 111 |
+
)
|
| 112 |
+
else:
|
| 113 |
+
raise ValueError(f"Unsupported perturbation type: {perturbation}")
|
| 114 |
+
return streams
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_sample_seed(
|
| 118 |
+
source_path: Path,
|
| 119 |
+
target_path: Path,
|
| 120 |
+
mask_path: Path | None,
|
| 121 |
+
perturbation_type: str,
|
| 122 |
+
perturbation_intensity: float,
|
| 123 |
+
) -> int:
|
| 124 |
+
sample_key = "|".join(
|
| 125 |
+
[
|
| 126 |
+
source_path.parent.parent.name,
|
| 127 |
+
source_path.stem,
|
| 128 |
+
target_path.parent.parent.name,
|
| 129 |
+
target_path.stem,
|
| 130 |
+
mask_path.stem if mask_path is not None else "no-mask",
|
| 131 |
+
perturbation_type,
|
| 132 |
+
f"{perturbation_intensity:.8f}",
|
| 133 |
+
str(LEGACY_CONTRASTIVE_FLAG),
|
| 134 |
+
]
|
| 135 |
+
)
|
| 136 |
+
digest = hashlib.sha256(sample_key.encode("utf-8")).digest()
|
| 137 |
+
return int.from_bytes(digest[:8], byteorder="big", signed=False) % (2**31)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def prepare_sample(
|
| 141 |
+
source_path: str | Path,
|
| 142 |
+
target_path: str | Path,
|
| 143 |
+
mask_path: str | Path | None,
|
| 144 |
+
img_size: int,
|
| 145 |
+
perturbation_type: str,
|
| 146 |
+
perturbation_intensity: float,
|
| 147 |
+
) -> PreparedSample:
|
| 148 |
+
source_path = Path(source_path)
|
| 149 |
+
target_path = Path(target_path)
|
| 150 |
+
mask_path = Path(mask_path) if mask_path is not None else None
|
| 151 |
+
|
| 152 |
+
source_image = Image.open(source_path).convert("RGB")
|
| 153 |
+
target_image = Image.open(target_path).convert("RGB")
|
| 154 |
+
mask_image = Image.open(mask_path) if mask_path is not None else None
|
| 155 |
+
source_image, target_image, mask_image = resize_triplet(source_image, target_image, mask_image, img_size)
|
| 156 |
+
|
| 157 |
+
sample_seed = build_sample_seed(
|
| 158 |
+
source_path=source_path,
|
| 159 |
+
target_path=target_path,
|
| 160 |
+
mask_path=mask_path,
|
| 161 |
+
perturbation_type=perturbation_type,
|
| 162 |
+
perturbation_intensity=perturbation_intensity,
|
| 163 |
+
)
|
| 164 |
+
transforms = SAM2Transforms(resolution=img_size, mask_threshold=0.0)
|
| 165 |
+
|
| 166 |
+
orig_tensor = TF.to_tensor(target_image)
|
| 167 |
+
orig = transforms.transforms(orig_tensor).unsqueeze(0).squeeze(0)
|
| 168 |
+
streams_raw = build_streams(
|
| 169 |
+
target_image=target_image,
|
| 170 |
+
perturbation_type=perturbation_type,
|
| 171 |
+
perturbation_intensity=perturbation_intensity,
|
| 172 |
+
seed=sample_seed,
|
| 173 |
+
)
|
| 174 |
+
streams = [transforms.transforms(stream).unsqueeze(0).squeeze(0) for stream in streams_raw]
|
| 175 |
+
|
| 176 |
+
mask_tensor = None
|
| 177 |
+
if mask_image is not None:
|
| 178 |
+
binary_mask = create_combined_mask(
|
| 179 |
+
mask_rgba=np.array(mask_image),
|
| 180 |
+
)
|
| 181 |
+
if binary_mask.shape != (img_size, img_size):
|
| 182 |
+
binary_mask = cv2.resize(binary_mask, (img_size, img_size), interpolation=cv2.INTER_NEAREST)
|
| 183 |
+
mask_tensor = torch.tensor(binary_mask, dtype=torch.float32).unsqueeze(0)
|
| 184 |
+
|
| 185 |
+
return PreparedSample(
|
| 186 |
+
name=target_path.stem,
|
| 187 |
+
source_path=source_path,
|
| 188 |
+
target_path=target_path,
|
| 189 |
+
mask_path=mask_path,
|
| 190 |
+
source_image=source_image,
|
| 191 |
+
target_image=target_image,
|
| 192 |
+
orig=orig,
|
| 193 |
+
streams=streams,
|
| 194 |
+
mask=mask_tensor,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class PairDataset(Dataset):
|
| 199 |
+
def __init__(
|
| 200 |
+
self,
|
| 201 |
+
root_dir: str | Path,
|
| 202 |
+
img_size: int,
|
| 203 |
+
perturbation_type: str,
|
| 204 |
+
perturbation_intensity: float,
|
| 205 |
+
max_samples: int | None = None,
|
| 206 |
+
) -> None:
|
| 207 |
+
self.root_dir = Path(root_dir)
|
| 208 |
+
self.source_dir = self.root_dir / "source"
|
| 209 |
+
self.target_dir = self.root_dir / "target"
|
| 210 |
+
self.mask_dir = self.root_dir / "mask"
|
| 211 |
+
self.img_size = img_size
|
| 212 |
+
self.perturbation_type = perturbation_type
|
| 213 |
+
self.perturbation_intensity = perturbation_intensity
|
| 214 |
+
|
| 215 |
+
if not self.source_dir.exists() or not self.target_dir.exists():
|
| 216 |
+
raise FileNotFoundError(f"{root_dir} must contain source/ and target/ directories")
|
| 217 |
+
|
| 218 |
+
target_files = sorted(
|
| 219 |
+
path
|
| 220 |
+
for path in self.target_dir.iterdir()
|
| 221 |
+
if path.suffix.lower() in {".png", ".jpg", ".jpeg"}
|
| 222 |
+
)
|
| 223 |
+
if max_samples is not None:
|
| 224 |
+
target_files = target_files[:max_samples]
|
| 225 |
+
self.target_files = target_files
|
| 226 |
+
|
| 227 |
+
def __len__(self) -> int:
|
| 228 |
+
return len(self.target_files)
|
| 229 |
+
|
| 230 |
+
def __getitem__(self, index: int) -> PreparedSample:
|
| 231 |
+
target_path = self.target_files[index]
|
| 232 |
+
source_path = self.source_dir / target_path.name
|
| 233 |
+
if not source_path.exists():
|
| 234 |
+
png_fallback = self.source_dir / f"{target_path.stem}.png"
|
| 235 |
+
jpg_fallback = self.source_dir / f"{target_path.stem}.jpg"
|
| 236 |
+
source_path = png_fallback if png_fallback.exists() else jpg_fallback
|
| 237 |
+
if not source_path.exists():
|
| 238 |
+
raise FileNotFoundError(f"Could not find source image for {target_path.name}")
|
| 239 |
+
|
| 240 |
+
mask_candidate = self.mask_dir / f"{target_path.stem}.png"
|
| 241 |
+
mask_path = mask_candidate if mask_candidate.exists() else None
|
| 242 |
+
return prepare_sample(
|
| 243 |
+
source_path=source_path,
|
| 244 |
+
target_path=target_path,
|
| 245 |
+
mask_path=mask_path,
|
| 246 |
+
img_size=self.img_size,
|
| 247 |
+
perturbation_type=self.perturbation_type,
|
| 248 |
+
perturbation_intensity=self.perturbation_intensity,
|
| 249 |
+
)
|
detectivesam_inference/evaluate.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from tqdm import tqdm
|
| 8 |
+
|
| 9 |
+
from detectivesam_inference.dataset import PairDataset
|
| 10 |
+
from detectivesam_inference.metrics import compute_f1, compute_iou, summarize_results
|
| 11 |
+
from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
|
| 12 |
+
from detectivesam_inference.visualization import save_prediction_outputs
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_args() -> argparse.Namespace:
|
| 16 |
+
repo_root = get_repo_root()
|
| 17 |
+
parser = argparse.ArgumentParser(description="Evaluate DetectiveSAM on a dataset root with source/target/mask folders.")
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--checkpoint",
|
| 20 |
+
default="detective_sam",
|
| 21 |
+
help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota.",
|
| 22 |
+
)
|
| 23 |
+
parser.add_argument("--dataset-root", default=str(repo_root / "demo" / "cocoglide"))
|
| 24 |
+
parser.add_argument("--output-dir", default=str(repo_root / "outputs" / "eval_demo"))
|
| 25 |
+
parser.add_argument("--device", default=None)
|
| 26 |
+
parser.add_argument("--threshold", type=float, default=0.5)
|
| 27 |
+
parser.add_argument("--max-samples", type=int, default=None)
|
| 28 |
+
parser.add_argument("--num-visualizations", type=int, default=4)
|
| 29 |
+
return parser.parse_args()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
args = parse_args()
|
| 34 |
+
runner = DetectiveSAMRunner(checkpoint_path=args.checkpoint, device=args.device)
|
| 35 |
+
dataset = PairDataset(
|
| 36 |
+
root_dir=args.dataset_root,
|
| 37 |
+
img_size=runner.config.img_size,
|
| 38 |
+
perturbation_type=runner.config.perturbation_type,
|
| 39 |
+
perturbation_intensity=runner.config.perturbation_intensity,
|
| 40 |
+
max_samples=args.max_samples,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
output_dir = Path(args.output_dir)
|
| 44 |
+
vis_dir = output_dir / "visualizations"
|
| 45 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
vis_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
|
| 48 |
+
per_sample_results: list[dict[str, float | str | None]] = []
|
| 49 |
+
for index, sample in enumerate(tqdm(dataset, desc="Evaluating")):
|
| 50 |
+
prediction = runner.predict_sample(sample, threshold=args.threshold)
|
| 51 |
+
gt_mask = sample.mask.squeeze().numpy().astype("uint8") if sample.mask is not None else None
|
| 52 |
+
per_sample_results.append(
|
| 53 |
+
{
|
| 54 |
+
"name": sample.name,
|
| 55 |
+
"iou": compute_iou(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
|
| 56 |
+
"f1": compute_f1(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
|
| 57 |
+
}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
if index < args.num_visualizations:
|
| 61 |
+
save_prediction_outputs(
|
| 62 |
+
output_dir=vis_dir,
|
| 63 |
+
name=sample.name,
|
| 64 |
+
source_image=sample.source_image,
|
| 65 |
+
target_image=sample.target_image,
|
| 66 |
+
probability_map=prediction.probability,
|
| 67 |
+
pred_mask=prediction.pred_mask,
|
| 68 |
+
gt_mask=gt_mask,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
payload = {
|
| 72 |
+
"checkpoint": str(runner.checkpoint_path.resolve()),
|
| 73 |
+
"dataset_root": str(Path(args.dataset_root).resolve()),
|
| 74 |
+
"threshold": args.threshold,
|
| 75 |
+
"summary": summarize_results(per_sample_results),
|
| 76 |
+
"samples": per_sample_results,
|
| 77 |
+
}
|
| 78 |
+
with (output_dir / "summary.json").open("w", encoding="utf-8") as handle:
|
| 79 |
+
json.dump(payload, handle, indent=2)
|
| 80 |
+
|
| 81 |
+
print(json.dumps(payload["summary"], indent=2))
|
| 82 |
+
print(f"Detailed results written to {output_dir / 'summary.json'}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
main()
|
detectivesam_inference/metrics.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def compute_iou(pred_mask: np.ndarray, true_mask: np.ndarray) -> float:
|
| 7 |
+
pred = pred_mask.astype(bool)
|
| 8 |
+
true = true_mask.astype(bool)
|
| 9 |
+
intersection = np.logical_and(pred, true).sum()
|
| 10 |
+
union = np.logical_or(pred, true).sum()
|
| 11 |
+
if union == 0:
|
| 12 |
+
return 1.0
|
| 13 |
+
return float(intersection / union)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def compute_f1(pred_mask: np.ndarray, true_mask: np.ndarray) -> float:
|
| 17 |
+
pred = pred_mask.astype(bool)
|
| 18 |
+
true = true_mask.astype(bool)
|
| 19 |
+
if not pred.any() and not true.any():
|
| 20 |
+
return 1.0
|
| 21 |
+
if not pred.any() or not true.any():
|
| 22 |
+
return 0.0
|
| 23 |
+
|
| 24 |
+
true_positive = np.logical_and(pred, true).sum()
|
| 25 |
+
false_positive = np.logical_and(pred, np.logical_not(true)).sum()
|
| 26 |
+
false_negative = np.logical_and(np.logical_not(pred), true).sum()
|
| 27 |
+
|
| 28 |
+
precision = true_positive / (true_positive + false_positive) if (true_positive + false_positive) else 0.0
|
| 29 |
+
recall = true_positive / (true_positive + false_negative) if (true_positive + false_negative) else 0.0
|
| 30 |
+
if precision + recall == 0:
|
| 31 |
+
return 0.0
|
| 32 |
+
return float(2 * precision * recall / (precision + recall))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def summarize_results(results: list[dict[str, float | str | None]]) -> dict[str, int | float | None]:
|
| 36 |
+
if not results:
|
| 37 |
+
return {
|
| 38 |
+
"num_samples": 0,
|
| 39 |
+
"num_samples_with_gt": 0,
|
| 40 |
+
"mean_iou": None,
|
| 41 |
+
"mean_f1": None,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
with_ground_truth = [item for item in results if item.get("iou") is not None]
|
| 45 |
+
return {
|
| 46 |
+
"num_samples": len(results),
|
| 47 |
+
"num_samples_with_gt": len(with_ground_truth),
|
| 48 |
+
"mean_iou": float(np.mean([item["iou"] for item in with_ground_truth])) if with_ground_truth else None,
|
| 49 |
+
"mean_f1": float(np.mean([item["f1"] for item in with_ground_truth])) if with_ground_truth else None,
|
| 50 |
+
}
|
detectivesam_inference/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from detectivesam_inference.models.forgerylocalizer import ForgeryLocalizer
|
| 2 |
+
|
| 3 |
+
__all__ = ["ForgeryLocalizer"]
|
detectivesam_inference/models/adapters.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
FeaturePyramid = list[torch.Tensor]
|
| 9 |
+
StreamPyramid = list[list[torch.Tensor]]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SharedAdapter(nn.Module):
|
| 13 |
+
"""Applies a residual adapter to each feature scale."""
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
in_channels_list: list[int],
|
| 18 |
+
hidden_dim: int,
|
| 19 |
+
dropout_rate: float = 0.1,
|
| 20 |
+
max_streams: int = 2,
|
| 21 |
+
) -> None:
|
| 22 |
+
super().__init__()
|
| 23 |
+
max_streams = max(max_streams, 1)
|
| 24 |
+
|
| 25 |
+
self.mlps_tune = nn.ModuleList(
|
| 26 |
+
nn.Conv2d(max_streams * channels, hidden_dim, kernel_size=1)
|
| 27 |
+
for channels in in_channels_list
|
| 28 |
+
)
|
| 29 |
+
self.mlps_bottleneck = nn.ModuleList(
|
| 30 |
+
nn.Sequential(nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1), nn.GELU())
|
| 31 |
+
for _ in in_channels_list
|
| 32 |
+
)
|
| 33 |
+
self.mlp_up = nn.ModuleList(
|
| 34 |
+
nn.Conv2d(hidden_dim, channels, kernel_size=1)
|
| 35 |
+
for channels in in_channels_list
|
| 36 |
+
)
|
| 37 |
+
self.activation = nn.GELU()
|
| 38 |
+
self.dropout = nn.Dropout2d(p=dropout_rate)
|
| 39 |
+
|
| 40 |
+
def forward(
|
| 41 |
+
self,
|
| 42 |
+
stream_features: list[torch.Tensor],
|
| 43 |
+
unadapted: torch.Tensor,
|
| 44 |
+
scale_idx: int,
|
| 45 |
+
) -> torch.Tensor:
|
| 46 |
+
fused_streams = torch.cat(stream_features, dim=1) if stream_features else unadapted
|
| 47 |
+
hidden = self.mlps_tune[scale_idx](fused_streams)
|
| 48 |
+
hidden = self.activation(hidden)
|
| 49 |
+
hidden = self.dropout(hidden)
|
| 50 |
+
hidden = self.mlps_bottleneck[scale_idx](hidden)
|
| 51 |
+
delta = self.mlp_up[scale_idx](hidden)
|
| 52 |
+
return unadapted + delta
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class RefineBlock(nn.Module):
|
| 56 |
+
"""Refines the coarse mask with low-level features."""
|
| 57 |
+
|
| 58 |
+
def __init__(
|
| 59 |
+
self,
|
| 60 |
+
hidden_dim: int,
|
| 61 |
+
low_channels: int,
|
| 62 |
+
out_channels: int = 1,
|
| 63 |
+
dropout_rate: float = 0.0,
|
| 64 |
+
) -> None:
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.conv1 = nn.Conv2d(hidden_dim + low_channels, hidden_dim, kernel_size=3, padding=1)
|
| 67 |
+
self.activation1 = nn.GELU()
|
| 68 |
+
self.dropout1 = nn.Dropout2d(p=dropout_rate)
|
| 69 |
+
self.conv2 = nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1)
|
| 70 |
+
self.activation2 = nn.GELU()
|
| 71 |
+
self.dropout2 = nn.Dropout2d(p=dropout_rate)
|
| 72 |
+
self.conv3 = nn.Conv2d(hidden_dim, out_channels, kernel_size=1)
|
| 73 |
+
|
| 74 |
+
def forward(
|
| 75 |
+
self,
|
| 76 |
+
attention_features: torch.Tensor,
|
| 77 |
+
low_features: torch.Tensor,
|
| 78 |
+
coarse_upsampled: torch.Tensor,
|
| 79 |
+
) -> torch.Tensor:
|
| 80 |
+
refined = torch.cat([attention_features, low_features], dim=1)
|
| 81 |
+
refined = self.conv1(refined)
|
| 82 |
+
refined = self.activation1(refined)
|
| 83 |
+
refined = self.dropout1(refined)
|
| 84 |
+
refined = self.conv2(refined)
|
| 85 |
+
refined = self.activation2(refined)
|
| 86 |
+
refined = self.dropout2(refined)
|
| 87 |
+
delta = self.conv3(refined)
|
| 88 |
+
return coarse_upsampled + delta
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class CoarseProcessingBlock(nn.Module):
|
| 92 |
+
"""Adds transformer-based coarse reasoning before refinement."""
|
| 93 |
+
|
| 94 |
+
def __init__(
|
| 95 |
+
self,
|
| 96 |
+
hidden_dim: int,
|
| 97 |
+
attn_dim: int,
|
| 98 |
+
n_heads: int,
|
| 99 |
+
num_encoder_layers: int,
|
| 100 |
+
dropout_rate: float,
|
| 101 |
+
downscale: int,
|
| 102 |
+
) -> None:
|
| 103 |
+
super().__init__()
|
| 104 |
+
self.hidden_dim = hidden_dim
|
| 105 |
+
self.coarse_down = nn.Sequential(
|
| 106 |
+
nn.Conv2d(hidden_dim, hidden_dim, kernel_size=downscale, stride=downscale, groups=hidden_dim),
|
| 107 |
+
nn.Conv2d(hidden_dim, hidden_dim, kernel_size=1),
|
| 108 |
+
nn.GELU(),
|
| 109 |
+
nn.Dropout2d(p=dropout_rate),
|
| 110 |
+
)
|
| 111 |
+
self.pos_embed_conv = nn.Conv2d(2, hidden_dim, kernel_size=1)
|
| 112 |
+
self.pos_dropout = nn.Dropout2d(p=dropout_rate)
|
| 113 |
+
self.feat_proj = nn.Sequential(
|
| 114 |
+
nn.Linear(hidden_dim, attn_dim),
|
| 115 |
+
nn.Dropout(p=dropout_rate),
|
| 116 |
+
)
|
| 117 |
+
encoder_layer = nn.TransformerEncoderLayer(
|
| 118 |
+
d_model=attn_dim,
|
| 119 |
+
nhead=n_heads,
|
| 120 |
+
dim_feedforward=attn_dim * 4,
|
| 121 |
+
dropout=dropout_rate,
|
| 122 |
+
activation="gelu",
|
| 123 |
+
batch_first=True,
|
| 124 |
+
)
|
| 125 |
+
self.transformer_encoder = nn.TransformerEncoder(
|
| 126 |
+
encoder_layer,
|
| 127 |
+
num_layers=num_encoder_layers,
|
| 128 |
+
)
|
| 129 |
+
self.transformer_out = nn.Sequential(
|
| 130 |
+
nn.Linear(attn_dim, hidden_dim),
|
| 131 |
+
nn.Dropout(p=dropout_rate),
|
| 132 |
+
)
|
| 133 |
+
self.residual_gate_conv = nn.Sequential(
|
| 134 |
+
nn.Conv2d(hidden_dim * 2, hidden_dim // 4, kernel_size=3, padding=1),
|
| 135 |
+
nn.GELU(),
|
| 136 |
+
nn.Dropout2d(p=dropout_rate),
|
| 137 |
+
nn.Conv2d(hidden_dim // 4, 1, kernel_size=1),
|
| 138 |
+
)
|
| 139 |
+
self.cached_pos_encodings: dict[tuple[int, int], torch.Tensor] = {}
|
| 140 |
+
|
| 141 |
+
def _generate_pos_encoding(self, height: int, width: int) -> torch.Tensor:
|
| 142 |
+
device = self.pos_embed_conv.weight.device
|
| 143 |
+
y_pos = torch.linspace(-1, 1, height, device=device).view(height, 1).expand(height, width)
|
| 144 |
+
x_pos = torch.linspace(-1, 1, width, device=device).view(1, width).expand(height, width)
|
| 145 |
+
pos_grid = torch.stack([y_pos, x_pos], dim=0).unsqueeze(0)
|
| 146 |
+
return self.pos_embed_conv(pos_grid)
|
| 147 |
+
|
| 148 |
+
def _get_positional_encoding(self, batch_size: int, height: int, width: int) -> torch.Tensor:
|
| 149 |
+
key = (height, width)
|
| 150 |
+
device = self.pos_embed_conv.weight.device
|
| 151 |
+
if key not in self.cached_pos_encodings:
|
| 152 |
+
self.cached_pos_encodings[key] = self._generate_pos_encoding(height, width).detach()
|
| 153 |
+
|
| 154 |
+
cached_encoding = self.cached_pos_encodings[key]
|
| 155 |
+
if cached_encoding.device != device:
|
| 156 |
+
cached_encoding = cached_encoding.to(device)
|
| 157 |
+
self.cached_pos_encodings[key] = cached_encoding
|
| 158 |
+
return cached_encoding.expand(batch_size, -1, -1, -1)
|
| 159 |
+
|
| 160 |
+
def forward(self, fused: torch.Tensor) -> torch.Tensor:
|
| 161 |
+
coarse_features = self.coarse_down(fused)
|
| 162 |
+
batch_size, _, height, width = coarse_features.shape
|
| 163 |
+
|
| 164 |
+
pos_embed = self._get_positional_encoding(batch_size, height, width)
|
| 165 |
+
pos_embed = self.pos_dropout(pos_embed)
|
| 166 |
+
coarse_with_position = coarse_features + pos_embed
|
| 167 |
+
|
| 168 |
+
feature_sequence = coarse_with_position.flatten(2).permute(0, 2, 1)
|
| 169 |
+
feature_sequence = self.feat_proj(feature_sequence)
|
| 170 |
+
transformer_output = self.transformer_encoder(feature_sequence)
|
| 171 |
+
hidden = self.transformer_out(transformer_output)
|
| 172 |
+
hidden = hidden.permute(0, 2, 1).view(batch_size, self.hidden_dim, height, width)
|
| 173 |
+
|
| 174 |
+
gate_input = torch.cat([hidden, coarse_features], dim=1)
|
| 175 |
+
residual_gate = torch.sigmoid(self.residual_gate_conv(gate_input))
|
| 176 |
+
return residual_gate * hidden + (1 - residual_gate) * coarse_features
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class FineProcessingBlock(nn.Module):
|
| 180 |
+
"""Produces the coarse mask and uncertainty map."""
|
| 181 |
+
|
| 182 |
+
def __init__(self, hidden_dim: int, dropout_rate: float) -> None:
|
| 183 |
+
super().__init__()
|
| 184 |
+
self.feature_refinement = nn.Sequential(
|
| 185 |
+
nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
|
| 186 |
+
nn.GELU(),
|
| 187 |
+
nn.Dropout2d(p=dropout_rate),
|
| 188 |
+
nn.Conv2d(hidden_dim, hidden_dim, kernel_size=3, padding=1),
|
| 189 |
+
nn.GELU(),
|
| 190 |
+
nn.Dropout2d(p=dropout_rate),
|
| 191 |
+
)
|
| 192 |
+
self.coarse_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
|
| 193 |
+
self.uncertainty_head = nn.Conv2d(hidden_dim, 1, kernel_size=1)
|
| 194 |
+
|
| 195 |
+
def forward(
|
| 196 |
+
self,
|
| 197 |
+
hidden: torch.Tensor,
|
| 198 |
+
output_size: tuple[int, int],
|
| 199 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 200 |
+
hidden = self.feature_refinement(hidden)
|
| 201 |
+
coarse_logit = self.coarse_head(hidden)
|
| 202 |
+
uncertainty_logit = self.uncertainty_head(hidden)
|
| 203 |
+
|
| 204 |
+
coarse_mask = F.interpolate(
|
| 205 |
+
coarse_logit,
|
| 206 |
+
size=output_size,
|
| 207 |
+
mode="bilinear",
|
| 208 |
+
align_corners=False,
|
| 209 |
+
)
|
| 210 |
+
uncertainty_map = F.interpolate(
|
| 211 |
+
uncertainty_logit,
|
| 212 |
+
size=output_size,
|
| 213 |
+
mode="bilinear",
|
| 214 |
+
align_corners=False,
|
| 215 |
+
)
|
| 216 |
+
return hidden, coarse_mask, torch.sigmoid(uncertainty_map)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class FeatureFusionBlockSpatial(nn.Module):
|
| 220 |
+
"""Fuses original, adapted, and perturbed features with per-pixel attention."""
|
| 221 |
+
|
| 222 |
+
def __init__(
|
| 223 |
+
self,
|
| 224 |
+
in_channels_list: list[int],
|
| 225 |
+
hidden_dim: int = 128,
|
| 226 |
+
dropout_rate: float = 0.1,
|
| 227 |
+
max_streams: int = 2,
|
| 228 |
+
attn_reduction: int = 4,
|
| 229 |
+
) -> None:
|
| 230 |
+
super().__init__()
|
| 231 |
+
self.num_streams = 2 + max_streams
|
| 232 |
+
self.att_conv = nn.ModuleList()
|
| 233 |
+
self.proj_conv = nn.ModuleList()
|
| 234 |
+
|
| 235 |
+
for channels in in_channels_list:
|
| 236 |
+
total_channels = channels * self.num_streams
|
| 237 |
+
mid_channels = max(total_channels // attn_reduction, 8)
|
| 238 |
+
self.att_conv.append(
|
| 239 |
+
nn.Sequential(
|
| 240 |
+
nn.Conv2d(
|
| 241 |
+
total_channels,
|
| 242 |
+
mid_channels,
|
| 243 |
+
kernel_size=3,
|
| 244 |
+
padding=1,
|
| 245 |
+
groups=self.num_streams,
|
| 246 |
+
bias=False,
|
| 247 |
+
),
|
| 248 |
+
nn.GELU(),
|
| 249 |
+
nn.Conv2d(mid_channels, self.num_streams, kernel_size=1, bias=False),
|
| 250 |
+
)
|
| 251 |
+
)
|
| 252 |
+
self.proj_conv.append(
|
| 253 |
+
nn.Sequential(
|
| 254 |
+
nn.Conv2d(channels, hidden_dim, kernel_size=1),
|
| 255 |
+
nn.GELU(),
|
| 256 |
+
nn.Dropout2d(p=dropout_rate),
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
fusion_channels = hidden_dim * len(in_channels_list)
|
| 261 |
+
self.fuse_project = nn.Sequential(
|
| 262 |
+
nn.Conv2d(fusion_channels, hidden_dim, kernel_size=1),
|
| 263 |
+
nn.GELU(),
|
| 264 |
+
nn.Dropout2d(p=dropout_rate),
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
def forward(
|
| 268 |
+
self,
|
| 269 |
+
adapted: FeaturePyramid,
|
| 270 |
+
unadapted: FeaturePyramid,
|
| 271 |
+
streams_unadapted: StreamPyramid,
|
| 272 |
+
output_size: tuple[int, int],
|
| 273 |
+
) -> torch.Tensor:
|
| 274 |
+
fused_scales = []
|
| 275 |
+
for scale_idx, (att_head, projection) in enumerate(zip(self.att_conv, self.proj_conv)):
|
| 276 |
+
streams = [adapted[scale_idx], unadapted[scale_idx], *streams_unadapted[scale_idx]]
|
| 277 |
+
logits = att_head(torch.cat(streams, dim=1))
|
| 278 |
+
weights = F.softmax(logits, dim=1).unsqueeze(2)
|
| 279 |
+
fused = (torch.stack(streams, dim=1) * weights).sum(dim=1)
|
| 280 |
+
fused = projection(fused)
|
| 281 |
+
fused = F.interpolate(
|
| 282 |
+
fused,
|
| 283 |
+
size=output_size,
|
| 284 |
+
mode="bilinear",
|
| 285 |
+
align_corners=False,
|
| 286 |
+
)
|
| 287 |
+
fused_scales.append(fused)
|
| 288 |
+
|
| 289 |
+
return self.fuse_project(torch.cat(fused_scales, dim=1))
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class MaskAdapter(nn.Module):
|
| 293 |
+
"""Builds the prompt mask passed into the SAM decoder."""
|
| 294 |
+
|
| 295 |
+
def __init__(
|
| 296 |
+
self,
|
| 297 |
+
hidden_dim: int = 256,
|
| 298 |
+
downscale: int = 16,
|
| 299 |
+
output_resolution: tuple[int, int] = (128, 128),
|
| 300 |
+
in_channels_list: list[int] | None = None,
|
| 301 |
+
attn_dim: int = 16,
|
| 302 |
+
n_heads: int = 4,
|
| 303 |
+
num_encoder_layers: int = 2,
|
| 304 |
+
dropout_rate: float = 0.1,
|
| 305 |
+
max_streams: int = 2,
|
| 306 |
+
) -> None:
|
| 307 |
+
super().__init__()
|
| 308 |
+
channels = in_channels_list or [256, 32, 64]
|
| 309 |
+
self.downscale = downscale
|
| 310 |
+
self.output_resolution = output_resolution
|
| 311 |
+
|
| 312 |
+
self.feature_fusion = FeatureFusionBlockSpatial(
|
| 313 |
+
in_channels_list=channels,
|
| 314 |
+
hidden_dim=hidden_dim,
|
| 315 |
+
dropout_rate=dropout_rate,
|
| 316 |
+
max_streams=max_streams,
|
| 317 |
+
)
|
| 318 |
+
self.coarse_processor = CoarseProcessingBlock(
|
| 319 |
+
hidden_dim=hidden_dim,
|
| 320 |
+
attn_dim=attn_dim,
|
| 321 |
+
n_heads=n_heads,
|
| 322 |
+
num_encoder_layers=num_encoder_layers,
|
| 323 |
+
dropout_rate=dropout_rate,
|
| 324 |
+
downscale=downscale,
|
| 325 |
+
)
|
| 326 |
+
self.fine_processor = FineProcessingBlock(hidden_dim, dropout_rate)
|
| 327 |
+
self.spatial_gate = nn.Sequential(
|
| 328 |
+
nn.Conv2d(2, hidden_dim // 2, kernel_size=3, padding=1),
|
| 329 |
+
nn.GELU(),
|
| 330 |
+
nn.Dropout2d(p=dropout_rate),
|
| 331 |
+
nn.Conv2d(hidden_dim // 2, 1, kernel_size=1),
|
| 332 |
+
nn.Sigmoid(),
|
| 333 |
+
)
|
| 334 |
+
self.refine_head = RefineBlock(
|
| 335 |
+
hidden_dim=hidden_dim,
|
| 336 |
+
low_channels=32,
|
| 337 |
+
out_channels=1,
|
| 338 |
+
dropout_rate=dropout_rate,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
def forward(
|
| 342 |
+
self,
|
| 343 |
+
adapted: FeaturePyramid,
|
| 344 |
+
streams_unadapted: StreamPyramid,
|
| 345 |
+
unadapted: FeaturePyramid,
|
| 346 |
+
) -> torch.Tensor:
|
| 347 |
+
output_height, output_width = self.output_resolution
|
| 348 |
+
output_size = (output_height, output_width)
|
| 349 |
+
coarse_size = (output_height // self.downscale, output_width // self.downscale)
|
| 350 |
+
|
| 351 |
+
fused = self.feature_fusion(adapted, unadapted, streams_unadapted, output_size)
|
| 352 |
+
hidden = self.coarse_processor(fused)
|
| 353 |
+
if hidden.shape[-2:] != coarse_size:
|
| 354 |
+
hidden = F.adaptive_avg_pool2d(hidden, coarse_size)
|
| 355 |
+
|
| 356 |
+
hidden, coarse_mask, uncertainty_map = self.fine_processor(hidden, output_size)
|
| 357 |
+
attention_features = F.interpolate(hidden, size=output_size, mode="bilinear", align_corners=False)
|
| 358 |
+
low_features = F.interpolate(unadapted[1], size=output_size, mode="bilinear", align_corners=False)
|
| 359 |
+
refined_mask = self.refine_head(attention_features, low_features, coarse_mask)
|
| 360 |
+
|
| 361 |
+
spatial_gate = self.spatial_gate(torch.cat([coarse_mask, uncertainty_map], dim=1))
|
| 362 |
+
return spatial_gate * refined_mask + (1 - spatial_gate) * coarse_mask
|
detectivesam_inference/models/forgerylocalizer.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
from sam2.build_sam import build_sam2
|
| 7 |
+
from sam2.modeling.backbones.image_encoder import ImageEncoder
|
| 8 |
+
from sam2.modeling.sam.mask_decoder import MaskDecoder
|
| 9 |
+
from sam2.modeling.sam.prompt_encoder import PromptEncoder
|
| 10 |
+
from sam2.modeling.sam2_base import SAM2Base
|
| 11 |
+
from sam2.utils.transforms import SAM2Transforms
|
| 12 |
+
|
| 13 |
+
from detectivesam_inference.models.adapters import MaskAdapter, SharedAdapter
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
MODEL_CHANNELS = [256, 32, 64]
|
| 17 |
+
MASK_ADAPTER_RESOLUTION = (128, 128)
|
| 18 |
+
FeaturePyramid = list[torch.Tensor]
|
| 19 |
+
StreamPyramid = list[list[torch.Tensor]]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ForgeryLocalizer(nn.Module):
|
| 23 |
+
"""Inference-only DetectiveSAM model."""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
sam_config: str,
|
| 28 |
+
sam_checkpoint: str,
|
| 29 |
+
prompt_dim: int = 256,
|
| 30 |
+
output_resolution: tuple[int, int] = (512, 512),
|
| 31 |
+
downscale: int = 4,
|
| 32 |
+
dropout_rate: float = 0.1,
|
| 33 |
+
max_streams: int = 2,
|
| 34 |
+
device: str = "cpu",
|
| 35 |
+
) -> None:
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.output_resolution = output_resolution
|
| 38 |
+
|
| 39 |
+
sam = self._build_sam(
|
| 40 |
+
sam_config=sam_config,
|
| 41 |
+
sam_checkpoint=sam_checkpoint,
|
| 42 |
+
output_resolution=output_resolution,
|
| 43 |
+
device=device,
|
| 44 |
+
)
|
| 45 |
+
self.no_mem_embed = sam.no_mem_embed if hasattr(sam, "no_mem_embed") else None
|
| 46 |
+
self.encoder: ImageEncoder = sam.image_encoder
|
| 47 |
+
self.decoder: MaskDecoder = sam.sam_mask_decoder
|
| 48 |
+
self.sam_prompt_encoder: PromptEncoder = sam.sam_prompt_encoder
|
| 49 |
+
self.sam_prompt_encoder.image_embedding_size = MASK_ADAPTER_RESOLUTION
|
| 50 |
+
|
| 51 |
+
self._freeze_sam_components()
|
| 52 |
+
self.adapters = SharedAdapter(
|
| 53 |
+
hidden_dim=prompt_dim,
|
| 54 |
+
in_channels_list=MODEL_CHANNELS,
|
| 55 |
+
dropout_rate=dropout_rate,
|
| 56 |
+
max_streams=max_streams,
|
| 57 |
+
)
|
| 58 |
+
self.mask_adapter = MaskAdapter(
|
| 59 |
+
hidden_dim=prompt_dim,
|
| 60 |
+
output_resolution=MASK_ADAPTER_RESOLUTION,
|
| 61 |
+
downscale=downscale,
|
| 62 |
+
in_channels_list=MODEL_CHANNELS,
|
| 63 |
+
dropout_rate=dropout_rate,
|
| 64 |
+
max_streams=max_streams,
|
| 65 |
+
)
|
| 66 |
+
self.transforms = SAM2Transforms(resolution=sam.image_size, mask_threshold=0.0)
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def _build_sam(
|
| 70 |
+
*,
|
| 71 |
+
sam_config: str,
|
| 72 |
+
sam_checkpoint: str,
|
| 73 |
+
output_resolution: tuple[int, int],
|
| 74 |
+
device: str,
|
| 75 |
+
) -> SAM2Base:
|
| 76 |
+
sam: SAM2Base = build_sam2(sam_config, sam_checkpoint, device=device)
|
| 77 |
+
sam.image_size = output_resolution[0]
|
| 78 |
+
return sam
|
| 79 |
+
|
| 80 |
+
def _freeze_sam_components(self) -> None:
|
| 81 |
+
for module in (self.encoder, self.decoder, self.sam_prompt_encoder):
|
| 82 |
+
for parameter in module.parameters():
|
| 83 |
+
parameter.requires_grad = False
|
| 84 |
+
|
| 85 |
+
def _project_sam_features(self, backbone_features: FeaturePyramid) -> tuple[torch.Tensor, FeaturePyramid]:
|
| 86 |
+
image_embeddings = backbone_features[-1]
|
| 87 |
+
if self.no_mem_embed is not None:
|
| 88 |
+
image_embeddings = image_embeddings + self.no_mem_embed.reshape(1, 256, 1, 1).detach()
|
| 89 |
+
|
| 90 |
+
high_res_features = [
|
| 91 |
+
self.decoder.conv_s0(backbone_features[0]),
|
| 92 |
+
self.decoder.conv_s1(backbone_features[1]),
|
| 93 |
+
]
|
| 94 |
+
return image_embeddings, high_res_features
|
| 95 |
+
|
| 96 |
+
def _encode_original_and_streams(
|
| 97 |
+
self,
|
| 98 |
+
orig: torch.Tensor,
|
| 99 |
+
streams: list[torch.Tensor],
|
| 100 |
+
) -> tuple[FeaturePyramid, StreamPyramid]:
|
| 101 |
+
orig_backbone_features = self.encoder(orig)["backbone_fpn"]
|
| 102 |
+
orig_image_embeddings, orig_high_res_features = self._project_sam_features(orig_backbone_features)
|
| 103 |
+
|
| 104 |
+
stream_image_embeddings: list[torch.Tensor] = []
|
| 105 |
+
stream_high_res_features_0: list[torch.Tensor] = []
|
| 106 |
+
stream_high_res_features_1: list[torch.Tensor] = []
|
| 107 |
+
for stream in streams:
|
| 108 |
+
stream_backbone_features = self.encoder(stream)["backbone_fpn"]
|
| 109 |
+
stream_image_embedding, stream_high_res_features = self._project_sam_features(stream_backbone_features)
|
| 110 |
+
stream_image_embeddings.append(stream_image_embedding)
|
| 111 |
+
stream_high_res_features_0.append(stream_high_res_features[0])
|
| 112 |
+
stream_high_res_features_1.append(stream_high_res_features[1])
|
| 113 |
+
|
| 114 |
+
unadapted = [orig_image_embeddings, orig_high_res_features[0], orig_high_res_features[1]]
|
| 115 |
+
streams_unadapted = [stream_image_embeddings, stream_high_res_features_0, stream_high_res_features_1]
|
| 116 |
+
return unadapted, streams_unadapted
|
| 117 |
+
|
| 118 |
+
def _apply_adapters(
|
| 119 |
+
self,
|
| 120 |
+
unadapted: FeaturePyramid,
|
| 121 |
+
streams_unadapted: StreamPyramid,
|
| 122 |
+
) -> FeaturePyramid:
|
| 123 |
+
return [
|
| 124 |
+
self.adapters(streams_unadapted[scale_idx], unadapted[scale_idx], scale_idx)
|
| 125 |
+
for scale_idx in range(len(unadapted))
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
def _prepare_decoder_inputs(
|
| 129 |
+
self,
|
| 130 |
+
adapted: FeaturePyramid,
|
| 131 |
+
mask_prompt: torch.Tensor,
|
| 132 |
+
) -> tuple[torch.Tensor, FeaturePyramid, torch.Tensor]:
|
| 133 |
+
# Preserve the original interpolation steps so inference stays bit-exact.
|
| 134 |
+
image_embeddings = F.interpolate(
|
| 135 |
+
adapted[0],
|
| 136 |
+
size=adapted[0].shape[-2:],
|
| 137 |
+
mode="bilinear",
|
| 138 |
+
align_corners=False,
|
| 139 |
+
)
|
| 140 |
+
high_res_features = [
|
| 141 |
+
F.interpolate(
|
| 142 |
+
feature,
|
| 143 |
+
size=feature.shape[-2:],
|
| 144 |
+
mode="bilinear",
|
| 145 |
+
align_corners=False,
|
| 146 |
+
)
|
| 147 |
+
for feature in adapted[1:]
|
| 148 |
+
]
|
| 149 |
+
mask_prompt = F.interpolate(
|
| 150 |
+
mask_prompt,
|
| 151 |
+
size=mask_prompt.shape[-2:],
|
| 152 |
+
mode="nearest",
|
| 153 |
+
)
|
| 154 |
+
return image_embeddings, high_res_features, mask_prompt
|
| 155 |
+
|
| 156 |
+
def forward(self, orig: torch.Tensor, streams: list[torch.Tensor]) -> torch.Tensor:
|
| 157 |
+
unadapted, streams_unadapted = self._encode_original_and_streams(orig, streams)
|
| 158 |
+
adapted = self._apply_adapters(unadapted, streams_unadapted)
|
| 159 |
+
|
| 160 |
+
mask_prompt = self.mask_adapter(adapted, streams_unadapted, unadapted)
|
| 161 |
+
image_embeddings, high_res_features, mask_prompt = self._prepare_decoder_inputs(adapted, mask_prompt)
|
| 162 |
+
|
| 163 |
+
self.sam_prompt_encoder.image_embedding_size = image_embeddings.shape[-2:]
|
| 164 |
+
sparse_prompt_embeddings, dense_prompt_embeddings = self.sam_prompt_encoder(
|
| 165 |
+
points=None,
|
| 166 |
+
boxes=None,
|
| 167 |
+
masks=mask_prompt,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
mask_logits, _, _, _ = self.decoder(
|
| 171 |
+
image_embeddings=image_embeddings,
|
| 172 |
+
image_pe=self.sam_prompt_encoder.get_dense_pe(),
|
| 173 |
+
sparse_prompt_embeddings=sparse_prompt_embeddings,
|
| 174 |
+
dense_prompt_embeddings=dense_prompt_embeddings,
|
| 175 |
+
multimask_output=False,
|
| 176 |
+
repeat_image=False,
|
| 177 |
+
high_res_features=high_res_features,
|
| 178 |
+
)
|
| 179 |
+
return self.transforms.postprocess_masks(mask_logits, torch.Size(self.output_resolution))
|
detectivesam_inference/perturbations.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
import torchvision.transforms.functional as TF
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_spatial_gaussian_kernel(
|
| 12 |
+
kernel_size: int,
|
| 13 |
+
sigma: float,
|
| 14 |
+
channels: int,
|
| 15 |
+
device: torch.device,
|
| 16 |
+
) -> torch.Tensor:
|
| 17 |
+
radius = kernel_size // 2
|
| 18 |
+
coords = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32)
|
| 19 |
+
x_pos, y_pos = torch.meshgrid(coords, coords, indexing="ij")
|
| 20 |
+
gaussian = torch.exp(-(x_pos**2 + y_pos**2) / (2 * sigma**2))
|
| 21 |
+
gaussian /= gaussian.sum()
|
| 22 |
+
return gaussian.view(1, 1, kernel_size, kernel_size).repeat(channels, 1, 1, 1)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def apply_spatial_gaussian_blur(
|
| 26 |
+
video_tensor: torch.Tensor,
|
| 27 |
+
sigma: float,
|
| 28 |
+
kernel_sizes: tuple[int, ...] = (5,),
|
| 29 |
+
) -> torch.Tensor:
|
| 30 |
+
batch_size, channels, frames, height, width = video_tensor.shape
|
| 31 |
+
blurred = video_tensor
|
| 32 |
+
for kernel_size in kernel_sizes:
|
| 33 |
+
kernel = create_spatial_gaussian_kernel(kernel_size, sigma, channels, video_tensor.device)
|
| 34 |
+
padding = kernel_size // 2
|
| 35 |
+
reshaped = blurred.permute(0, 2, 1, 3, 4).reshape(batch_size * frames, channels, height, width)
|
| 36 |
+
reshaped = F.conv2d(reshaped, kernel, padding=padding, groups=channels)
|
| 37 |
+
blurred = reshaped.reshape(batch_size, frames, channels, height, width).permute(0, 2, 1, 3, 4)
|
| 38 |
+
return blurred
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def add_gaussian_noise_deterministic(
|
| 42 |
+
image: torch.Tensor,
|
| 43 |
+
*,
|
| 44 |
+
mean: float = 0.0,
|
| 45 |
+
std: float = 0.1,
|
| 46 |
+
seed: int,
|
| 47 |
+
) -> torch.Tensor:
|
| 48 |
+
generator = torch.Generator(device=image.device)
|
| 49 |
+
generator.manual_seed(seed)
|
| 50 |
+
noise = torch.randn(image.shape, generator=generator, device=image.device, dtype=image.dtype) * std + mean
|
| 51 |
+
return torch.clamp(image + noise, 0.0, 1.0)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def apply_blur_to_image_tensor(image_tensor: torch.Tensor, sigma: float) -> torch.Tensor:
|
| 55 |
+
if sigma <= 0:
|
| 56 |
+
return image_tensor.clone()
|
| 57 |
+
video_tensor = image_tensor.unsqueeze(0).unsqueeze(2)
|
| 58 |
+
blurred = apply_spatial_gaussian_blur(video_tensor, sigma=sigma, kernel_sizes=(5,))
|
| 59 |
+
return blurred.squeeze(0).squeeze(1)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def apply_jpeg_compression(image: Image.Image, quality: int = 75) -> Image.Image:
|
| 63 |
+
buffer = io.BytesIO()
|
| 64 |
+
image.save(buffer, format="JPEG", quality=quality)
|
| 65 |
+
buffer.seek(0)
|
| 66 |
+
return Image.open(buffer).convert("RGB")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def apply_jpeg_compression_to_tensor(image_tensor: torch.Tensor, quality: int) -> torch.Tensor:
|
| 70 |
+
image = TF.to_pil_image(image_tensor)
|
| 71 |
+
return TF.to_tensor(apply_jpeg_compression(image, quality=quality))
|
detectivesam_inference/predict.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from detectivesam_inference.dataset import prepare_sample
|
| 8 |
+
from detectivesam_inference.metrics import compute_f1, compute_iou
|
| 9 |
+
from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
|
| 10 |
+
from detectivesam_inference.visualization import save_prediction_outputs
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEFAULT_DEMO_NAME = "banana_28809"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def resolve_demo_defaults(repo_root: Path) -> tuple[Path | None, Path, Path | None, str]:
|
| 17 |
+
user_demo_target = repo_root / "demo" / "user_image" / "demo_input.png"
|
| 18 |
+
fallback_source = repo_root / "demo" / "cocoglide" / "source" / f"{DEFAULT_DEMO_NAME}.png"
|
| 19 |
+
fallback_target = repo_root / "demo" / "cocoglide" / "target" / f"{DEFAULT_DEMO_NAME}.png"
|
| 20 |
+
fallback_mask = repo_root / "demo" / "cocoglide" / "mask" / f"{DEFAULT_DEMO_NAME}.png"
|
| 21 |
+
|
| 22 |
+
if user_demo_target.exists():
|
| 23 |
+
return None, user_demo_target, None, "single_image"
|
| 24 |
+
return fallback_source, fallback_target, fallback_mask, "pair"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def parse_args() -> argparse.Namespace:
|
| 28 |
+
repo_root = get_repo_root()
|
| 29 |
+
parser = argparse.ArgumentParser(description="Run DetectiveSAM on a single source/target pair.")
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--checkpoint",
|
| 32 |
+
default="detective_sam",
|
| 33 |
+
help="Checkpoint path or alias. Built-in aliases: detective_sam, detective_sam_sota.",
|
| 34 |
+
)
|
| 35 |
+
parser.add_argument("--source", default=None, help="Optional source image. If omitted, target is reused as source.")
|
| 36 |
+
parser.add_argument(
|
| 37 |
+
"--target",
|
| 38 |
+
default=None,
|
| 39 |
+
help="Target image. If omitted, uses demo/user_image/demo_input.png when present, else falls back to the bundled CocoGlide pair.",
|
| 40 |
+
)
|
| 41 |
+
parser.add_argument("--mask", default=None, help="Optional ground-truth mask for metrics.")
|
| 42 |
+
parser.add_argument("--output-dir", default=str(repo_root / "outputs" / "predict_demo"))
|
| 43 |
+
parser.add_argument("--device", default=None)
|
| 44 |
+
parser.add_argument("--threshold", type=float, default=0.5)
|
| 45 |
+
return parser.parse_args()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def resolve_input_paths(args: argparse.Namespace, repo_root: Path) -> tuple[Path, Path, Path | None, str]:
|
| 49 |
+
demo_source, demo_target, demo_mask, demo_mode = resolve_demo_defaults(repo_root)
|
| 50 |
+
if args.target is not None:
|
| 51 |
+
target_path = Path(args.target)
|
| 52 |
+
source_path = Path(args.source) if args.source else target_path
|
| 53 |
+
mask_path = Path(args.mask) if args.mask else None
|
| 54 |
+
return source_path, target_path, mask_path, "custom"
|
| 55 |
+
|
| 56 |
+
target_path = demo_target
|
| 57 |
+
source_path = Path(args.source) if args.source else (demo_source or target_path)
|
| 58 |
+
mask_path = Path(args.mask) if args.mask else demo_mask
|
| 59 |
+
return source_path, target_path, mask_path, demo_mode
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main() -> None:
|
| 63 |
+
args = parse_args()
|
| 64 |
+
repo_root = get_repo_root()
|
| 65 |
+
source_path, target_path, mask_path, demo_mode = resolve_input_paths(args, repo_root)
|
| 66 |
+
reference_mode = "pair" if source_path != target_path else "target_as_source"
|
| 67 |
+
|
| 68 |
+
runner = DetectiveSAMRunner(checkpoint_path=args.checkpoint, device=args.device)
|
| 69 |
+
sample = prepare_sample(
|
| 70 |
+
source_path=source_path,
|
| 71 |
+
target_path=target_path,
|
| 72 |
+
mask_path=mask_path,
|
| 73 |
+
img_size=runner.config.img_size,
|
| 74 |
+
perturbation_type=runner.config.perturbation_type,
|
| 75 |
+
perturbation_intensity=runner.config.perturbation_intensity,
|
| 76 |
+
)
|
| 77 |
+
prediction = runner.predict_sample(sample, threshold=args.threshold)
|
| 78 |
+
|
| 79 |
+
gt_mask = sample.mask.squeeze().numpy().astype("uint8") if sample.mask is not None else None
|
| 80 |
+
summary = {
|
| 81 |
+
"sample": sample.name,
|
| 82 |
+
"checkpoint": str(runner.checkpoint_path.resolve()),
|
| 83 |
+
"demo_mode": demo_mode,
|
| 84 |
+
"reference_mode": reference_mode,
|
| 85 |
+
"source": str(sample.source_path),
|
| 86 |
+
"target": str(sample.target_path),
|
| 87 |
+
"mask": str(sample.mask_path) if sample.mask_path is not None else None,
|
| 88 |
+
"threshold": args.threshold,
|
| 89 |
+
"metrics": {
|
| 90 |
+
"iou": compute_iou(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
|
| 91 |
+
"f1": compute_f1(prediction.pred_mask, gt_mask) if gt_mask is not None else None,
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
output_dir = Path(args.output_dir)
|
| 96 |
+
save_prediction_outputs(
|
| 97 |
+
output_dir=output_dir,
|
| 98 |
+
name=sample.name,
|
| 99 |
+
source_image=sample.source_image,
|
| 100 |
+
target_image=sample.target_image,
|
| 101 |
+
probability_map=prediction.probability,
|
| 102 |
+
pred_mask=prediction.pred_mask,
|
| 103 |
+
gt_mask=gt_mask,
|
| 104 |
+
)
|
| 105 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
with (output_dir / f"{sample.name}_summary.json").open("w", encoding="utf-8") as handle:
|
| 107 |
+
json.dump(summary, handle, indent=2)
|
| 108 |
+
|
| 109 |
+
print(json.dumps(summary, indent=2))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
main()
|
detectivesam_inference/runtime.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from contextlib import nullcontext
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from hydra import initialize_config_dir
|
| 10 |
+
from hydra.core.global_hydra import GlobalHydra
|
| 11 |
+
|
| 12 |
+
from detectivesam_inference.checkpoint import (
|
| 13 |
+
InferenceConfig,
|
| 14 |
+
load_inference_config,
|
| 15 |
+
resolve_checkpoint_path,
|
| 16 |
+
resolve_repo_path,
|
| 17 |
+
)
|
| 18 |
+
from detectivesam_inference.dataset import PreparedSample
|
| 19 |
+
from detectivesam_inference.models.forgerylocalizer import ForgeryLocalizer
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class PredictionResult:
|
| 24 |
+
probability: np.ndarray
|
| 25 |
+
pred_mask: np.ndarray
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_repo_root() -> Path:
|
| 29 |
+
return Path(__file__).resolve().parent.parent
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def select_device(device: str | None = None) -> torch.device:
|
| 33 |
+
if device is not None:
|
| 34 |
+
return torch.device(device)
|
| 35 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def initialize_sam2_config(config_dir: str | Path) -> None:
|
| 39 |
+
config_dir = str(Path(config_dir).resolve())
|
| 40 |
+
hydra = GlobalHydra.instance()
|
| 41 |
+
current_dir = getattr(initialize_sam2_config, "_current_dir", None)
|
| 42 |
+
if hydra.is_initialized():
|
| 43 |
+
if current_dir == config_dir:
|
| 44 |
+
return
|
| 45 |
+
hydra.clear()
|
| 46 |
+
initialize_config_dir(config_dir=config_dir, version_base=None)
|
| 47 |
+
initialize_sam2_config._current_dir = config_dir
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class DetectiveSAMRunner:
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
checkpoint_path: str | Path | None = None,
|
| 54 |
+
device: str | None = None,
|
| 55 |
+
) -> None:
|
| 56 |
+
self.repo_root = get_repo_root()
|
| 57 |
+
self.checkpoint_path = resolve_checkpoint_path(checkpoint_path, self.repo_root)
|
| 58 |
+
self.device = select_device(device)
|
| 59 |
+
self.config = load_inference_config(self.checkpoint_path)
|
| 60 |
+
self.model = self._load_model()
|
| 61 |
+
|
| 62 |
+
def _load_model(self) -> ForgeryLocalizer:
|
| 63 |
+
sam_config_path = resolve_repo_path(self.config.sam_config_file, self.repo_root)
|
| 64 |
+
sam_checkpoint_path = resolve_repo_path(self.config.sam_checkpoint, self.repo_root)
|
| 65 |
+
initialize_sam2_config(sam_config_path.parent)
|
| 66 |
+
|
| 67 |
+
model = ForgeryLocalizer(
|
| 68 |
+
sam_config=sam_config_path.name,
|
| 69 |
+
sam_checkpoint=str(sam_checkpoint_path),
|
| 70 |
+
prompt_dim=self.config.prompt_dim,
|
| 71 |
+
downscale=self.config.downscale,
|
| 72 |
+
dropout_rate=self.config.dropout_rate,
|
| 73 |
+
max_streams=self.config.max_streams,
|
| 74 |
+
device=str(self.device),
|
| 75 |
+
).to(self.device)
|
| 76 |
+
|
| 77 |
+
checkpoint = torch.load(self.checkpoint_path, map_location=self.device, weights_only=False)
|
| 78 |
+
state_dict = checkpoint["model"] if isinstance(checkpoint, dict) and "model" in checkpoint else checkpoint
|
| 79 |
+
model.load_state_dict(state_dict)
|
| 80 |
+
model.eval()
|
| 81 |
+
return model
|
| 82 |
+
|
| 83 |
+
def autocast_context(self):
|
| 84 |
+
if self.device.type == "cuda":
|
| 85 |
+
return torch.amp.autocast(device_type="cuda")
|
| 86 |
+
return nullcontext()
|
| 87 |
+
|
| 88 |
+
def predict_sample(
|
| 89 |
+
self,
|
| 90 |
+
sample: PreparedSample,
|
| 91 |
+
threshold: float = 0.5,
|
| 92 |
+
) -> PredictionResult:
|
| 93 |
+
orig = sample.orig.unsqueeze(0).to(self.device)
|
| 94 |
+
streams = [stream.unsqueeze(0).to(self.device) for stream in sample.streams]
|
| 95 |
+
|
| 96 |
+
with torch.inference_mode():
|
| 97 |
+
with self.autocast_context():
|
| 98 |
+
logits = self.model(orig, streams)
|
| 99 |
+
|
| 100 |
+
probability = torch.sigmoid(logits).squeeze().detach().cpu().numpy()
|
| 101 |
+
pred_mask = (probability > threshold).astype("uint8")
|
| 102 |
+
return PredictionResult(probability=probability, pred_mask=pred_mask)
|
detectivesam_inference/visualization.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image, ImageOps
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def ensure_uint8(mask: np.ndarray) -> np.ndarray:
|
| 10 |
+
if mask.dtype == np.bool_:
|
| 11 |
+
return mask.astype(np.uint8) * 255
|
| 12 |
+
if mask.max() <= 1.0:
|
| 13 |
+
return (mask * 255).astype(np.uint8)
|
| 14 |
+
return mask.astype(np.uint8)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def mask_to_image(mask: np.ndarray) -> Image.Image:
|
| 18 |
+
return Image.fromarray(ensure_uint8(mask), mode="L")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def probability_to_image(probability: np.ndarray) -> Image.Image:
|
| 22 |
+
clipped = np.clip(probability, 0.0, 1.0)
|
| 23 |
+
return Image.fromarray((clipped * 255).astype(np.uint8), mode="L")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def overlay_mask(
|
| 27 |
+
image: Image.Image,
|
| 28 |
+
mask: np.ndarray,
|
| 29 |
+
color: tuple[int, int, int],
|
| 30 |
+
alpha: float = 0.45,
|
| 31 |
+
) -> Image.Image:
|
| 32 |
+
base = np.array(image.convert("RGB"), dtype=np.float32)
|
| 33 |
+
overlay = base.copy()
|
| 34 |
+
overlay[mask.astype(bool)] = (1.0 - alpha) * overlay[mask.astype(bool)] + alpha * np.array(color, dtype=np.float32)
|
| 35 |
+
return Image.fromarray(np.clip(overlay, 0, 255).astype(np.uint8), mode="RGB")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def concat_images(images: list[Image.Image]) -> Image.Image:
|
| 39 |
+
widths, heights = zip(*(image.size for image in images))
|
| 40 |
+
canvas = Image.new("RGB", (sum(widths), max(heights)), color=(255, 255, 255))
|
| 41 |
+
x_offset = 0
|
| 42 |
+
for image in images:
|
| 43 |
+
canvas.paste(image.convert("RGB"), (x_offset, 0))
|
| 44 |
+
x_offset += image.width
|
| 45 |
+
return canvas
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def save_prediction_outputs(
|
| 49 |
+
output_dir: str | Path,
|
| 50 |
+
name: str,
|
| 51 |
+
source_image: Image.Image,
|
| 52 |
+
target_image: Image.Image,
|
| 53 |
+
probability_map: np.ndarray,
|
| 54 |
+
pred_mask: np.ndarray,
|
| 55 |
+
gt_mask: np.ndarray | None = None,
|
| 56 |
+
) -> None:
|
| 57 |
+
output_dir = Path(output_dir)
|
| 58 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
probability_image = probability_to_image(probability_map)
|
| 61 |
+
pred_mask_image = mask_to_image(pred_mask)
|
| 62 |
+
pred_overlay = overlay_mask(target_image, pred_mask, color=(255, 0, 0))
|
| 63 |
+
|
| 64 |
+
comparison_images = [
|
| 65 |
+
source_image.convert("RGB"),
|
| 66 |
+
target_image.convert("RGB"),
|
| 67 |
+
ImageOps.colorize(probability_image, black="black", white="white").convert("RGB"),
|
| 68 |
+
pred_overlay,
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
probability_image.save(output_dir / f"{name}_probability.png")
|
| 72 |
+
pred_mask_image.save(output_dir / f"{name}_pred_mask.png")
|
| 73 |
+
pred_overlay.save(output_dir / f"{name}_pred_overlay.png")
|
| 74 |
+
|
| 75 |
+
if gt_mask is not None:
|
| 76 |
+
gt_mask_image = mask_to_image(gt_mask)
|
| 77 |
+
gt_overlay = overlay_mask(target_image, gt_mask, color=(0, 255, 0))
|
| 78 |
+
gt_mask_image.save(output_dir / f"{name}_gt_mask.png")
|
| 79 |
+
gt_overlay.save(output_dir / f"{name}_gt_overlay.png")
|
| 80 |
+
comparison_images.append(gt_overlay)
|
| 81 |
+
|
| 82 |
+
concat_images(comparison_images).save(output_dir / f"{name}_comparison.png")
|
pytest.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.4
|
| 2 |
+
torchvision>=0.19
|
| 3 |
+
numpy>=1.24
|
| 4 |
+
Pillow>=10.0
|
| 5 |
+
opencv-python-headless>=4.10
|
| 6 |
+
hydra-core>=1.3
|
| 7 |
+
sam2
|
| 8 |
+
tqdm>=4.66
|
| 9 |
+
|
sam2configs/sam2.1_hiera_b+.yaml
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# @package _global_
|
| 2 |
+
|
| 3 |
+
# Model
|
| 4 |
+
model:
|
| 5 |
+
_target_: sam2.modeling.sam2_base.SAM2Base
|
| 6 |
+
image_encoder:
|
| 7 |
+
_target_: sam2.modeling.backbones.image_encoder.ImageEncoder
|
| 8 |
+
scalp: 1
|
| 9 |
+
trunk:
|
| 10 |
+
_target_: sam2.modeling.backbones.hieradet.Hiera
|
| 11 |
+
embed_dim: 112
|
| 12 |
+
num_heads: 2
|
| 13 |
+
neck:
|
| 14 |
+
_target_: sam2.modeling.backbones.image_encoder.FpnNeck
|
| 15 |
+
position_encoding:
|
| 16 |
+
_target_: sam2.modeling.position_encoding.PositionEmbeddingSine
|
| 17 |
+
num_pos_feats: 256
|
| 18 |
+
normalize: true
|
| 19 |
+
scale: null
|
| 20 |
+
temperature: 10000
|
| 21 |
+
d_model: 256
|
| 22 |
+
backbone_channel_list: [896, 448, 224, 112]
|
| 23 |
+
fpn_top_down_levels: [2, 3] # output level 0 and 1 directly use the backbone features
|
| 24 |
+
fpn_interp_model: nearest
|
| 25 |
+
|
| 26 |
+
memory_attention:
|
| 27 |
+
_target_: sam2.modeling.memory_attention.MemoryAttention
|
| 28 |
+
d_model: 256
|
| 29 |
+
pos_enc_at_input: true
|
| 30 |
+
layer:
|
| 31 |
+
_target_: sam2.modeling.memory_attention.MemoryAttentionLayer
|
| 32 |
+
activation: relu
|
| 33 |
+
dim_feedforward: 2048
|
| 34 |
+
dropout: 0.1
|
| 35 |
+
pos_enc_at_attn: false
|
| 36 |
+
self_attention:
|
| 37 |
+
_target_: sam2.modeling.sam.transformer.RoPEAttention
|
| 38 |
+
rope_theta: 10000.0
|
| 39 |
+
feat_sizes: [64, 64]
|
| 40 |
+
embedding_dim: 256
|
| 41 |
+
num_heads: 1
|
| 42 |
+
downsample_rate: 1
|
| 43 |
+
dropout: 0.1
|
| 44 |
+
d_model: 256
|
| 45 |
+
pos_enc_at_cross_attn_keys: true
|
| 46 |
+
pos_enc_at_cross_attn_queries: false
|
| 47 |
+
cross_attention:
|
| 48 |
+
_target_: sam2.modeling.sam.transformer.RoPEAttention
|
| 49 |
+
rope_theta: 10000.0
|
| 50 |
+
feat_sizes: [64, 64]
|
| 51 |
+
rope_k_repeat: True
|
| 52 |
+
embedding_dim: 256
|
| 53 |
+
num_heads: 1
|
| 54 |
+
downsample_rate: 1
|
| 55 |
+
dropout: 0.1
|
| 56 |
+
kv_in_dim: 64
|
| 57 |
+
num_layers: 4
|
| 58 |
+
|
| 59 |
+
memory_encoder:
|
| 60 |
+
_target_: sam2.modeling.memory_encoder.MemoryEncoder
|
| 61 |
+
out_dim: 64
|
| 62 |
+
position_encoding:
|
| 63 |
+
_target_: sam2.modeling.position_encoding.PositionEmbeddingSine
|
| 64 |
+
num_pos_feats: 64
|
| 65 |
+
normalize: true
|
| 66 |
+
scale: null
|
| 67 |
+
temperature: 10000
|
| 68 |
+
mask_downsampler:
|
| 69 |
+
_target_: sam2.modeling.memory_encoder.MaskDownSampler
|
| 70 |
+
kernel_size: 3
|
| 71 |
+
stride: 2
|
| 72 |
+
padding: 1
|
| 73 |
+
fuser:
|
| 74 |
+
_target_: sam2.modeling.memory_encoder.Fuser
|
| 75 |
+
layer:
|
| 76 |
+
_target_: sam2.modeling.memory_encoder.CXBlock
|
| 77 |
+
dim: 256
|
| 78 |
+
kernel_size: 7
|
| 79 |
+
padding: 3
|
| 80 |
+
layer_scale_init_value: 1e-6
|
| 81 |
+
use_dwconv: True # depth-wise convs
|
| 82 |
+
num_layers: 2
|
| 83 |
+
|
| 84 |
+
num_maskmem: 7
|
| 85 |
+
image_size: 1024
|
| 86 |
+
# apply scaled sigmoid on mask logits for memory encoder, and directly feed input mask as output mask
|
| 87 |
+
sigmoid_scale_for_mem_enc: 20.0
|
| 88 |
+
sigmoid_bias_for_mem_enc: -10.0
|
| 89 |
+
use_mask_input_as_output_without_sam: true
|
| 90 |
+
# Memory
|
| 91 |
+
directly_add_no_mem_embed: true
|
| 92 |
+
no_obj_embed_spatial: true
|
| 93 |
+
# use high-resolution feature map in the SAM mask decoder
|
| 94 |
+
use_high_res_features_in_sam: true
|
| 95 |
+
# output 3 masks on the first click on initial conditioning frames
|
| 96 |
+
multimask_output_in_sam: true
|
| 97 |
+
# SAM heads
|
| 98 |
+
iou_prediction_use_sigmoid: True
|
| 99 |
+
# cross-attend to object pointers from other frames (based on SAM output tokens) in the encoder
|
| 100 |
+
use_obj_ptrs_in_encoder: true
|
| 101 |
+
add_tpos_enc_to_obj_ptrs: true
|
| 102 |
+
proj_tpos_enc_in_obj_ptrs: true
|
| 103 |
+
use_signed_tpos_enc_to_obj_ptrs: true
|
| 104 |
+
only_obj_ptrs_in_the_past_for_eval: true
|
| 105 |
+
# object occlusion prediction
|
| 106 |
+
pred_obj_scores: true
|
| 107 |
+
pred_obj_scores_mlp: true
|
| 108 |
+
fixed_no_obj_ptr: true
|
| 109 |
+
# multimask tracking settings
|
| 110 |
+
multimask_output_for_tracking: true
|
| 111 |
+
use_multimask_token_for_obj_ptr: true
|
| 112 |
+
multimask_min_pt_num: 0
|
| 113 |
+
multimask_max_pt_num: 1
|
| 114 |
+
use_mlp_for_obj_ptr_proj: true
|
| 115 |
+
# Compilation flag
|
| 116 |
+
compile_image_encoder: False
|
sam2configs/sam2.1_hiera_base_plus.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2345aede8715ab1d5d31b4a509fb160c5a4af1970f199d9054ccfb746c004c5
|
| 3 |
+
size 323606802
|
tests/test_regression.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from detectivesam_inference.checkpoint import resolve_checkpoint_path
|
| 9 |
+
from detectivesam_inference.dataset import PairDataset, prepare_sample
|
| 10 |
+
from detectivesam_inference.metrics import compute_f1, compute_iou, summarize_results
|
| 11 |
+
from detectivesam_inference.runtime import DetectiveSAMRunner, get_repo_root
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def assert_exact(value: float | None, expected: float | None, *, abs_tol: float = 1e-12) -> None:
|
| 15 |
+
assert value is not None
|
| 16 |
+
assert expected is not None
|
| 17 |
+
assert math.isclose(value, expected, rel_tol=0.0, abs_tol=abs_tol)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture(scope="module")
|
| 21 |
+
def repo_root() -> Path:
|
| 22 |
+
return get_repo_root()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture(scope="module")
|
| 26 |
+
def baseline_runner() -> DetectiveSAMRunner:
|
| 27 |
+
return DetectiveSAMRunner(checkpoint_path="detective_sam", device="cpu")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture(scope="module")
|
| 31 |
+
def sota_runner() -> DetectiveSAMRunner:
|
| 32 |
+
return DetectiveSAMRunner(checkpoint_path="detective_sam_sota", device="cpu")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def predict_metrics(
|
| 36 |
+
runner: DetectiveSAMRunner,
|
| 37 |
+
*,
|
| 38 |
+
source_path: Path,
|
| 39 |
+
target_path: Path,
|
| 40 |
+
mask_path: Path,
|
| 41 |
+
) -> tuple[float, float]:
|
| 42 |
+
sample = prepare_sample(
|
| 43 |
+
source_path=source_path,
|
| 44 |
+
target_path=target_path,
|
| 45 |
+
mask_path=mask_path,
|
| 46 |
+
img_size=runner.config.img_size,
|
| 47 |
+
perturbation_type=runner.config.perturbation_type,
|
| 48 |
+
perturbation_intensity=runner.config.perturbation_intensity,
|
| 49 |
+
)
|
| 50 |
+
prediction = runner.predict_sample(sample, threshold=0.5)
|
| 51 |
+
true_mask = sample.mask.squeeze().numpy().astype("uint8")
|
| 52 |
+
return compute_iou(prediction.pred_mask, true_mask), compute_f1(prediction.pred_mask, true_mask)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_checkpoint_alias_resolution(repo_root: Path) -> None:
|
| 56 |
+
assert resolve_checkpoint_path("detective_sam", repo_root) == repo_root / "checkpoints" / "model_epoch22_batch999_score1.1114.pth"
|
| 57 |
+
assert resolve_checkpoint_path("detective_sam_sota", repo_root) == repo_root / "checkpoints" / "detective_sam_sota.pth"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_baseline_banana_demo_metrics(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
|
| 61 |
+
demo_root = repo_root / "demo" / "cocoglide"
|
| 62 |
+
iou, f1 = predict_metrics(
|
| 63 |
+
baseline_runner,
|
| 64 |
+
source_path=demo_root / "source" / "banana_28809.png",
|
| 65 |
+
target_path=demo_root / "target" / "banana_28809.png",
|
| 66 |
+
mask_path=demo_root / "mask" / "banana_28809.png",
|
| 67 |
+
)
|
| 68 |
+
assert_exact(iou, 0.8566427949370513)
|
| 69 |
+
assert_exact(f1, 0.9227868680750683)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_sota_flux_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
|
| 73 |
+
demo_root = repo_root / "demo" / "flux_test"
|
| 74 |
+
iou, f1 = predict_metrics(
|
| 75 |
+
sota_runner,
|
| 76 |
+
source_path=demo_root / "source" / "548.png",
|
| 77 |
+
target_path=demo_root / "target" / "548.png",
|
| 78 |
+
mask_path=demo_root / "mask" / "548.png",
|
| 79 |
+
)
|
| 80 |
+
assert_exact(iou, 0.8703024868799283)
|
| 81 |
+
assert_exact(f1, 0.9306542583192329)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_sota_qwen_demo_metrics(repo_root: Path, sota_runner: DetectiveSAMRunner) -> None:
|
| 85 |
+
demo_root = repo_root / "demo" / "qwen_test"
|
| 86 |
+
iou, f1 = predict_metrics(
|
| 87 |
+
sota_runner,
|
| 88 |
+
source_path=demo_root / "source" / "166.png",
|
| 89 |
+
target_path=demo_root / "target" / "166.png",
|
| 90 |
+
mask_path=demo_root / "mask" / "166.png",
|
| 91 |
+
)
|
| 92 |
+
assert_exact(iou, 0.8297306693388413)
|
| 93 |
+
assert_exact(f1, 0.9069429542203147)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_baseline_cocoglide_eval_summary(repo_root: Path, baseline_runner: DetectiveSAMRunner) -> None:
|
| 97 |
+
dataset = PairDataset(
|
| 98 |
+
root_dir=repo_root / "demo" / "cocoglide",
|
| 99 |
+
img_size=baseline_runner.config.img_size,
|
| 100 |
+
perturbation_type=baseline_runner.config.perturbation_type,
|
| 101 |
+
perturbation_intensity=baseline_runner.config.perturbation_intensity,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
per_sample_results: list[dict[str, float | str | None]] = []
|
| 105 |
+
for sample in dataset:
|
| 106 |
+
prediction = baseline_runner.predict_sample(sample, threshold=0.5)
|
| 107 |
+
true_mask = sample.mask.squeeze().numpy().astype("uint8")
|
| 108 |
+
per_sample_results.append(
|
| 109 |
+
{
|
| 110 |
+
"name": sample.name,
|
| 111 |
+
"iou": compute_iou(prediction.pred_mask, true_mask),
|
| 112 |
+
"f1": compute_f1(prediction.pred_mask, true_mask),
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
summary = summarize_results(per_sample_results)
|
| 117 |
+
assert summary["num_samples"] == 5
|
| 118 |
+
assert summary["num_samples_with_gt"] == 5
|
| 119 |
+
assert_exact(summary["mean_iou"], 0.5092573070481035)
|
| 120 |
+
assert_exact(summary["mean_f1"], 0.6509390858765342)
|
| 121 |
+
|
| 122 |
+
expected_by_name = {
|
| 123 |
+
"airplane_139871": (0.41829717560376584, 0.5898582931686339),
|
| 124 |
+
"banana_28809": (0.8566427949370513, 0.9227868680750683),
|
| 125 |
+
"giraffe_296969": (0.22833093957714018, 0.3717743031951054),
|
| 126 |
+
"train_221213": (0.547253866814856, 0.7073872989458688),
|
| 127 |
+
"tv_453722": (0.49576175830770386, 0.662888665997994),
|
| 128 |
+
}
|
| 129 |
+
for result in per_sample_results:
|
| 130 |
+
expected_iou, expected_f1 = expected_by_name[result["name"]]
|
| 131 |
+
assert_exact(result["iou"], expected_iou)
|
| 132 |
+
assert_exact(result["f1"], expected_f1)
|