Spaces:
Running
Running
Deploy A-EYE remote full ensemble backend
Browse files- .gitattributes +1 -33
- Dockerfile +23 -31
- README_HF_ENSEMBLE.md +66 -0
- app.py +15 -0
- requirements.txt +8 -10
- scripts/app_production.py +788 -0
- src/__init__.py +0 -0
- src/api/__init__.py +1 -0
- src/data/__init__.py +2 -0
- src/data/all_sources_dataset.py +321 -0
- src/data/artifact_transforms.py +294 -0
- src/data/dataset.py +359 -0
- src/data/transforms.py +109 -0
- src/inference/__init__.py +1 -0
- src/models/__init__.py +5 -0
- src/models/artifact_gated_hybrid.py +143 -0
- src/models/artifact_hybrid.py +151 -0
- src/models/clip_classifier.py +403 -0
- src/models/factory.py +187 -0
- src/models/fft_branch.py +145 -0
- src/models/hybrid.py +238 -0
- src/models/lora_hybrid.py +172 -0
- src/models/lora_hybrid_fft.py +166 -0
- src/models/srm_branch.py +149 -0
- src/models/vit_classifier.py +165 -0
- src/training/__init__.py +2 -0
- src/training/metrics.py +180 -0
- src/training/seed_utils.py +51 -0
- src/training/trainer.py +630 -0
- src/xai/__init__.py +3 -0
- src/xai/visual_explainer.py +359 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,3 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,31 +1,23 @@
|
|
| 1 |
-
# A-EYE
|
| 2 |
-
FROM python:3.11-slim
|
| 3 |
-
|
| 4 |
-
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
-
PIP_NO_CACHE_DIR=1 \
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
&&
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
COPY
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
# 모델 다운로드/캐시가 가능하도록 쓰기 가능 디렉터리 보장
|
| 26 |
-
# (Hugging Face Spaces 등 비루트 사용자로 실행되는 환경 대비)
|
| 27 |
-
RUN mkdir -p /app/models && chmod -R 777 /app/models
|
| 28 |
-
|
| 29 |
-
# 클라우드 플랫폼이 주입하는 $PORT 를 사용합니다(없으면 8000).
|
| 30 |
-
EXPOSE 8000
|
| 31 |
-
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
|
|
|
| 1 |
+
# A-EYE production ensemble backend for Hugging Face Docker Spaces.
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
A_EYE_MODELS=32,37,34,36 \
|
| 7 |
+
A_EYE_MODEL_REPO=wonjun12/aeye-ensemble-models \
|
| 8 |
+
A_EYE_RANDOM_INIT_BACKBONES=1
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
RUN apt-get update \
|
| 13 |
+
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --upgrade pip \
|
| 18 |
+
&& pip install -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
CMD ["sh", "-c", "python -u app.py"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README_HF_ENSEMBLE.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A-EYE Hugging Face Ensemble Backend
|
| 2 |
+
|
| 3 |
+
This backend runs the production ensemble server.
|
| 4 |
+
|
| 5 |
+
Recommended model set:
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
A_EYE_MODELS=32,37,34,36
|
| 9 |
+
A_EYE_MODEL_REPO=wonjun12/aeye-ensemble-models
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
Required files:
|
| 13 |
+
|
| 14 |
+
```text
|
| 15 |
+
app.py
|
| 16 |
+
Dockerfile
|
| 17 |
+
requirements.txt
|
| 18 |
+
scripts/app_production.py
|
| 19 |
+
src/
|
| 20 |
+
experiments/32_practical_quality_balanced/config.yaml
|
| 21 |
+
experiments/32_practical_quality_balanced/checkpoints/best32.pt
|
| 22 |
+
experiments/37_real_fp_mined_balanced/config.yaml
|
| 23 |
+
experiments/37_real_fp_mined_balanced/checkpoints/best.pt
|
| 24 |
+
experiments/34_latest_hardpair_balanced/config.yaml
|
| 25 |
+
experiments/34_latest_hardpair_balanced/checkpoints/best.pt
|
| 26 |
+
experiments/36_realworld_guard_v2/config.yaml
|
| 27 |
+
experiments/36_realworld_guard_v2/checkpoints/best.pt
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Run command:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
python -u app.py
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Docker Spaces will use the included `Dockerfile`, which runs the same command.
|
| 37 |
+
|
| 38 |
+
For the full ensemble on a free Space, keep large checkpoints in a separate
|
| 39 |
+
model repository and let the Space download them at startup:
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
wonjun12/aeye-ensemble-models
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
If the model repository is private, add a Space secret named `HF_TOKEN` with
|
| 46 |
+
read access to that model repository.
|
| 47 |
+
|
| 48 |
+
Health check:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
curl https://YOUR_SPACE_URL/api/health
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Prediction endpoint:
|
| 55 |
+
|
| 56 |
+
```text
|
| 57 |
+
POST /api/predict
|
| 58 |
+
multipart form field: image
|
| 59 |
+
optional fields: mode=production, xai=0 or 1
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Notes:
|
| 63 |
+
|
| 64 |
+
- CPU Spaces will be slow because four checkpoints are loaded.
|
| 65 |
+
- A GPU Space is recommended for app deployment.
|
| 66 |
+
- Large `.pt` files should be uploaded with Git LFS or `huggingface-cli upload`.
|
app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Spaces entrypoint for the A-EYE ensemble backend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
os.environ.setdefault("A_EYE_MODELS", "32,37,34,36")
|
| 8 |
+
os.environ.setdefault("A_EYE_MODEL_REPO", "wonjun12/aeye-ensemble-models")
|
| 9 |
+
os.environ.setdefault("A_EYE_PORT", os.environ.get("PORT", "7860"))
|
| 10 |
+
|
| 11 |
+
from scripts.app_production import main # noqa: E402
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
main()
|
requirements.txt
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
grad-cam==1.5.4
|
| 10 |
-
python-dotenv==1.0.1
|
|
|
|
| 1 |
+
flask==3.0.3
|
| 2 |
+
pyyaml>=6.0
|
| 3 |
+
pillow>=10.0.0
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
torch>=2.1.0
|
| 6 |
+
torchvision>=0.16.0
|
| 7 |
+
transformers>=4.38.0
|
| 8 |
+
huggingface_hub>=0.24.0
|
|
|
|
|
|
scripts/app_production.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Production-style A-EYE tester with ensemble triage and XAI.
|
| 2 |
+
|
| 3 |
+
This server is meant for app/backend testing. It reduces confident mistakes by
|
| 4 |
+
combining several strong checkpoints and returning "review" when evidence is
|
| 5 |
+
not stable enough for a paid user-facing decision.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import io
|
| 11 |
+
import copy
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
from hashlib import sha1
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from threading import Lock
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
import yaml
|
| 25 |
+
from flask import Flask, jsonify, render_template_string, request
|
| 26 |
+
from PIL import Image, ImageOps
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 30 |
+
if str(ROOT) not in sys.path:
|
| 31 |
+
sys.path.insert(0, str(ROOT))
|
| 32 |
+
|
| 33 |
+
DEVICE = torch.device(
|
| 34 |
+
"cpu"
|
| 35 |
+
if os.environ.get("A_EYE_FORCE_CPU", "").strip().lower() in {"1", "true", "yes"}
|
| 36 |
+
else ("cuda" if torch.cuda.is_available() else "cpu")
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
FEEDBACK_DIR = ROOT / "hard_examples" / "production_feedback"
|
| 40 |
+
DEFAULT_MODELS = ("32", "37", "34", "36")
|
| 41 |
+
REMOTE_DOWNLOAD_LOGGED: set[str] = set()
|
| 42 |
+
|
| 43 |
+
MODEL_REGISTRY = {
|
| 44 |
+
"38": {
|
| 45 |
+
"exp": "38_paid_service_balanced",
|
| 46 |
+
"label": "38번 유료 서비스 균형 모델",
|
| 47 |
+
"role": "실제사진 오탐과 AI 미탐 동시 개선",
|
| 48 |
+
"weight": 0.48,
|
| 49 |
+
},
|
| 50 |
+
"32": {
|
| 51 |
+
"exp": "32_practical_quality_balanced",
|
| 52 |
+
"label": "32번 실사용 균형 모델",
|
| 53 |
+
"role": "전체 균형과 최신 AI 탐지",
|
| 54 |
+
"weight": 0.45,
|
| 55 |
+
},
|
| 56 |
+
"37": {
|
| 57 |
+
"exp": "37_real_fp_mined_balanced",
|
| 58 |
+
"label": "37번 실제사진 보호 모델",
|
| 59 |
+
"role": "실제사진 오탐 억제",
|
| 60 |
+
"weight": 0.30,
|
| 61 |
+
},
|
| 62 |
+
"34": {
|
| 63 |
+
"exp": "34_latest_hardpair_balanced",
|
| 64 |
+
"label": "34번 최신 hardpair 모델",
|
| 65 |
+
"role": "고품질 AI 이미지 보강",
|
| 66 |
+
"weight": 0.15,
|
| 67 |
+
},
|
| 68 |
+
"36": {
|
| 69 |
+
"exp": "36_realworld_guard_v2",
|
| 70 |
+
"label": "36번 realworld guard v2",
|
| 71 |
+
"role": "저조도와 웹 이미지 안정성",
|
| 72 |
+
"weight": 0.10,
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class ModelState:
|
| 79 |
+
model_id: str
|
| 80 |
+
exp_name: str
|
| 81 |
+
label: str
|
| 82 |
+
role: str
|
| 83 |
+
weight: float
|
| 84 |
+
model: torch.nn.Module
|
| 85 |
+
transform: object
|
| 86 |
+
config: dict
|
| 87 |
+
checkpoint: Path
|
| 88 |
+
threshold: float
|
| 89 |
+
checkpoint_mtime: float
|
| 90 |
+
loaded_at: str
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class EnsembleState:
|
| 95 |
+
models: list[ModelState]
|
| 96 |
+
loaded_at: str
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
app = Flask(__name__)
|
| 100 |
+
app.config["MAX_CONTENT_LENGTH"] = 36 * 1024 * 1024
|
| 101 |
+
|
| 102 |
+
STATE: EnsembleState | None = None
|
| 103 |
+
STATE_LOCK = Lock()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _read_json(path: Path, default):
|
| 107 |
+
try:
|
| 108 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 109 |
+
except Exception:
|
| 110 |
+
return default
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _download_model_file(rel_path: Path) -> bool:
|
| 114 |
+
repo_id = os.environ.get("A_EYE_MODEL_REPO", "").strip()
|
| 115 |
+
if not repo_id:
|
| 116 |
+
return False
|
| 117 |
+
dst = ROOT / rel_path
|
| 118 |
+
if dst.exists() and dst.stat().st_size > 0:
|
| 119 |
+
return True
|
| 120 |
+
try:
|
| 121 |
+
from huggingface_hub import hf_hub_download
|
| 122 |
+
|
| 123 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or None
|
| 124 |
+
cached = hf_hub_download(
|
| 125 |
+
repo_id=repo_id,
|
| 126 |
+
repo_type=os.environ.get("A_EYE_MODEL_REPO_TYPE", "model"),
|
| 127 |
+
filename=rel_path.as_posix(),
|
| 128 |
+
token=token,
|
| 129 |
+
)
|
| 130 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
shutil.copy2(cached, dst)
|
| 132 |
+
key = rel_path.as_posix()
|
| 133 |
+
if key not in REMOTE_DOWNLOAD_LOGGED:
|
| 134 |
+
print(f"[A-EYE production] downloaded {key} from {repo_id}", flush=True)
|
| 135 |
+
REMOTE_DOWNLOAD_LOGGED.add(key)
|
| 136 |
+
return True
|
| 137 |
+
except Exception as exc:
|
| 138 |
+
key = rel_path.as_posix()
|
| 139 |
+
if key not in REMOTE_DOWNLOAD_LOGGED:
|
| 140 |
+
print(f"[A-EYE production] remote model file unavailable: {key} ({exc})", flush=True)
|
| 141 |
+
REMOTE_DOWNLOAD_LOGGED.add(key)
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _ensure_experiment_files(exp_name: str) -> None:
|
| 146 |
+
exp_rel = Path("experiments") / exp_name
|
| 147 |
+
for rel in [
|
| 148 |
+
exp_rel / "config.yaml",
|
| 149 |
+
exp_rel / "threshold_report.json",
|
| 150 |
+
exp_rel / "history.json",
|
| 151 |
+
exp_rel / "checkpoints" / "best.pt",
|
| 152 |
+
exp_rel / "checkpoints" / "best32.pt",
|
| 153 |
+
]:
|
| 154 |
+
_download_model_file(rel)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _checkpoint_for(exp_name: str) -> tuple[Path, Path]:
|
| 158 |
+
_ensure_experiment_files(exp_name)
|
| 159 |
+
exp_dir = ROOT / "experiments" / exp_name
|
| 160 |
+
ckpt_dir = exp_dir / "checkpoints"
|
| 161 |
+
ckpt = exp_dir / "checkpoints" / "best.pt"
|
| 162 |
+
cfg = exp_dir / "config.yaml"
|
| 163 |
+
if not ckpt.exists():
|
| 164 |
+
alternates = [
|
| 165 |
+
ckpt_dir / "best32.pt",
|
| 166 |
+
*sorted(ckpt_dir.glob("best*.pt"), key=lambda p: p.stat().st_mtime, reverse=True),
|
| 167 |
+
*sorted(ckpt_dir.glob("epoch_*.pt"), key=lambda p: p.stat().st_mtime, reverse=True),
|
| 168 |
+
]
|
| 169 |
+
ckpt = next((path for path in alternates if path.exists()), ckpt)
|
| 170 |
+
if not ckpt.exists():
|
| 171 |
+
raise FileNotFoundError(f"checkpoint not found under: {ckpt_dir}")
|
| 172 |
+
if not cfg.exists():
|
| 173 |
+
raise FileNotFoundError(f"config not found: {cfg}")
|
| 174 |
+
return ckpt, cfg
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _threshold_from(exp_name: str, checkpoint_metrics: dict) -> float:
|
| 178 |
+
_ensure_experiment_files(exp_name)
|
| 179 |
+
exp_dir = ROOT / "experiments" / exp_name
|
| 180 |
+
threshold_report = _read_json(exp_dir / "threshold_report.json", {})
|
| 181 |
+
candidates = [
|
| 182 |
+
threshold_report,
|
| 183 |
+
checkpoint_metrics,
|
| 184 |
+
checkpoint_metrics.get("val", {}) if isinstance(checkpoint_metrics, dict) else {},
|
| 185 |
+
]
|
| 186 |
+
for source in candidates:
|
| 187 |
+
if not isinstance(source, dict):
|
| 188 |
+
continue
|
| 189 |
+
for key in ("threshold", "calibrated_threshold"):
|
| 190 |
+
try:
|
| 191 |
+
value = float(source[key])
|
| 192 |
+
except Exception:
|
| 193 |
+
continue
|
| 194 |
+
if 0.001 <= value <= 0.999:
|
| 195 |
+
return value
|
| 196 |
+
|
| 197 |
+
best_score = -1e9
|
| 198 |
+
best_threshold = None
|
| 199 |
+
history = _read_json(exp_dir / "history.json", [])
|
| 200 |
+
if isinstance(history, list):
|
| 201 |
+
for row in history:
|
| 202 |
+
val = row.get("val", {}) if isinstance(row, dict) else {}
|
| 203 |
+
score = val.get("calibrated_real_guarded_score", val.get("real_guarded_score"))
|
| 204 |
+
threshold = val.get("calibrated_threshold", val.get("threshold"))
|
| 205 |
+
try:
|
| 206 |
+
score_f = float(score)
|
| 207 |
+
threshold_f = float(threshold)
|
| 208 |
+
except Exception:
|
| 209 |
+
continue
|
| 210 |
+
if 0.001 <= threshold_f <= 0.999 and score_f > best_score:
|
| 211 |
+
best_score = score_f
|
| 212 |
+
best_threshold = threshold_f
|
| 213 |
+
return float(best_threshold) if best_threshold is not None else 0.5
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _model_ids_from_env() -> list[str]:
|
| 217 |
+
raw = os.environ.get("A_EYE_MODELS", ",".join(DEFAULT_MODELS))
|
| 218 |
+
ids = [part.strip() for part in raw.split(",") if part.strip()]
|
| 219 |
+
valid = [model_id for model_id in ids if model_id in MODEL_REGISTRY]
|
| 220 |
+
return valid or list(DEFAULT_MODELS)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _load_one(model_id: str) -> ModelState:
|
| 224 |
+
from src.data.artifact_transforms import get_artifact_transforms
|
| 225 |
+
from src.models.factory import build_model
|
| 226 |
+
|
| 227 |
+
spec = MODEL_REGISTRY[model_id]
|
| 228 |
+
ckpt_path, cfg_path = _checkpoint_for(spec["exp"])
|
| 229 |
+
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
| 230 |
+
model_cfg = copy.deepcopy(cfg)
|
| 231 |
+
if os.environ.get("A_EYE_RANDOM_INIT_BACKBONES", "").strip().lower() in {"1", "true", "yes"}:
|
| 232 |
+
model_cfg.setdefault("model", {})["pretrained"] = False
|
| 233 |
+
model = build_model(model_cfg)
|
| 234 |
+
checkpoint = torch.load(ckpt_path, map_location=DEVICE, weights_only=True)
|
| 235 |
+
state = checkpoint.get("model_state_dict", checkpoint)
|
| 236 |
+
model.load_state_dict(state)
|
| 237 |
+
model.to(DEVICE).eval()
|
| 238 |
+
|
| 239 |
+
transform = get_artifact_transforms(
|
| 240 |
+
"val",
|
| 241 |
+
image_size=int(cfg.get("data", {}).get("image_size", 224)),
|
| 242 |
+
)
|
| 243 |
+
threshold = _threshold_from(spec["exp"], checkpoint.get("metrics", {}))
|
| 244 |
+
return ModelState(
|
| 245 |
+
model_id=model_id,
|
| 246 |
+
exp_name=spec["exp"],
|
| 247 |
+
label=spec["label"],
|
| 248 |
+
role=spec["role"],
|
| 249 |
+
weight=float(spec["weight"]),
|
| 250 |
+
model=model,
|
| 251 |
+
transform=transform,
|
| 252 |
+
config=cfg,
|
| 253 |
+
checkpoint=ckpt_path,
|
| 254 |
+
threshold=threshold,
|
| 255 |
+
checkpoint_mtime=ckpt_path.stat().st_mtime,
|
| 256 |
+
loaded_at=datetime.now().isoformat(timespec="seconds"),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def load_ensemble(force: bool = False) -> EnsembleState:
|
| 261 |
+
global STATE
|
| 262 |
+
with STATE_LOCK:
|
| 263 |
+
model_ids = _model_ids_from_env()
|
| 264 |
+
current_key = [(m.model_id, m.checkpoint_mtime) for m in STATE.models] if STATE else None
|
| 265 |
+
wanted_key = []
|
| 266 |
+
for model_id in model_ids:
|
| 267 |
+
spec = MODEL_REGISTRY[model_id]
|
| 268 |
+
ckpt, _cfg = _checkpoint_for(spec["exp"])
|
| 269 |
+
wanted_key.append((model_id, ckpt.stat().st_mtime))
|
| 270 |
+
if not force and STATE is not None and current_key == wanted_key:
|
| 271 |
+
return STATE
|
| 272 |
+
|
| 273 |
+
models = [_load_one(model_id) for model_id in model_ids]
|
| 274 |
+
total_weight = sum(m.weight for m in models) or 1.0
|
| 275 |
+
for model_state in models:
|
| 276 |
+
model_state.weight = model_state.weight / total_weight
|
| 277 |
+
STATE = EnsembleState(models=models, loaded_at=datetime.now().isoformat(timespec="seconds"))
|
| 278 |
+
loaded = ", ".join(f"{m.model_id}(thr={m.threshold:.3f})" for m in models)
|
| 279 |
+
print(f"[A-EYE production] loaded {loaded} on {DEVICE}", flush=True)
|
| 280 |
+
return STATE
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _tta_batch(image: Image.Image, model_state: ModelState) -> torch.Tensor:
|
| 284 |
+
rgb = ImageOps.exif_transpose(image).convert("RGB")
|
| 285 |
+
variants = [rgb, ImageOps.mirror(rgb)]
|
| 286 |
+
return torch.stack([model_state.transform(variant) for variant in variants], dim=0).to(DEVICE)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _logits(model: torch.nn.Module, batch: torch.Tensor) -> torch.Tensor:
|
| 290 |
+
output = model(batch)
|
| 291 |
+
return output["logits"] if isinstance(output, dict) else output
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _predict_one(image: Image.Image, model_state: ModelState) -> dict:
|
| 295 |
+
start = time.perf_counter()
|
| 296 |
+
batch = _tta_batch(image, model_state)
|
| 297 |
+
with torch.no_grad():
|
| 298 |
+
with torch.amp.autocast(device_type=DEVICE.type, enabled=DEVICE.type == "cuda"):
|
| 299 |
+
logits = _logits(model_state.model, batch)
|
| 300 |
+
probs = torch.softmax(logits.float(), dim=1).mean(dim=0)
|
| 301 |
+
fake_prob = float(probs[1].detach().cpu())
|
| 302 |
+
vote = "fake" if fake_prob >= model_state.threshold else "real"
|
| 303 |
+
return {
|
| 304 |
+
"id": model_state.model_id,
|
| 305 |
+
"exp_name": model_state.exp_name,
|
| 306 |
+
"label": model_state.label,
|
| 307 |
+
"role": model_state.role,
|
| 308 |
+
"weight": round(model_state.weight, 4),
|
| 309 |
+
"threshold": round(model_state.threshold, 4),
|
| 310 |
+
"fake_prob": round(fake_prob, 6),
|
| 311 |
+
"real_prob": round(1.0 - fake_prob, 6),
|
| 312 |
+
"vote": vote,
|
| 313 |
+
"margin_from_threshold": round(fake_prob - model_state.threshold, 6),
|
| 314 |
+
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _policy(mode: str, model_count: int) -> dict:
|
| 319 |
+
mode = mode if mode in {"production", "real_guard", "sensitive"} else "production"
|
| 320 |
+
strong_votes = max(2, int(round(model_count * 0.75)))
|
| 321 |
+
if mode == "real_guard":
|
| 322 |
+
return {
|
| 323 |
+
"mode": mode,
|
| 324 |
+
"fake_min": 0.72,
|
| 325 |
+
"real_max": 0.38,
|
| 326 |
+
"fake_votes": strong_votes,
|
| 327 |
+
"real_votes": max(2, strong_votes - 1),
|
| 328 |
+
"max_disagreement": 0.34,
|
| 329 |
+
}
|
| 330 |
+
if mode == "sensitive":
|
| 331 |
+
return {
|
| 332 |
+
"mode": mode,
|
| 333 |
+
"fake_min": 0.56,
|
| 334 |
+
"real_max": 0.24,
|
| 335 |
+
"fake_votes": max(2, strong_votes - 1),
|
| 336 |
+
"real_votes": strong_votes,
|
| 337 |
+
"max_disagreement": 0.46,
|
| 338 |
+
}
|
| 339 |
+
return {
|
| 340 |
+
"mode": mode,
|
| 341 |
+
"fake_min": 0.66,
|
| 342 |
+
"real_max": 0.34,
|
| 343 |
+
"fake_votes": strong_votes,
|
| 344 |
+
"real_votes": strong_votes,
|
| 345 |
+
"max_disagreement": 0.40,
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def _decide(rows: list[dict], mode: str) -> dict:
|
| 350 |
+
policy = _policy(mode, len(rows))
|
| 351 |
+
weighted_fake = sum(row["fake_prob"] * row["weight"] for row in rows)
|
| 352 |
+
fake_votes = sum(1 for row in rows if row["vote"] == "fake")
|
| 353 |
+
real_votes = len(rows) - fake_votes
|
| 354 |
+
probs = [row["fake_prob"] for row in rows]
|
| 355 |
+
disagreement = max(probs) - min(probs) if probs else 0.0
|
| 356 |
+
|
| 357 |
+
verdict = "review"
|
| 358 |
+
reasons = []
|
| 359 |
+
if fake_votes >= policy["fake_votes"] and weighted_fake >= policy["fake_min"]:
|
| 360 |
+
if disagreement <= policy["max_disagreement"] or weighted_fake >= 0.82:
|
| 361 |
+
verdict = "fake"
|
| 362 |
+
reasons.append("여러 모델이 AI 쪽으로 동시에 강하게 기울었습니다.")
|
| 363 |
+
else:
|
| 364 |
+
reasons.append("AI 가능성은 높지만 모델 간 차이가 커서 검토가 필요합니다.")
|
| 365 |
+
elif real_votes >= policy["real_votes"] and weighted_fake <= policy["real_max"]:
|
| 366 |
+
if disagreement <= policy["max_disagreement"] or weighted_fake <= 0.18:
|
| 367 |
+
verdict = "real"
|
| 368 |
+
reasons.append("여러 모델이 실제사진 쪽으로 안정적으로 일치했습니다.")
|
| 369 |
+
else:
|
| 370 |
+
reasons.append("실제사진 가능성은 높지만 일부 모델이 반대 신호를 냅니다.")
|
| 371 |
+
else:
|
| 372 |
+
reasons.append("유료 서비스 기준으로는 확신 판정을 내리기 부족합니다.")
|
| 373 |
+
|
| 374 |
+
if verdict == "review":
|
| 375 |
+
if 0.42 <= weighted_fake <= 0.58:
|
| 376 |
+
reasons.append("AI 확률이 중앙값 근처라 애매합니다.")
|
| 377 |
+
if fake_votes and real_votes:
|
| 378 |
+
reasons.append(f"모델 투표가 AI {fake_votes}개, 실제 {real_votes}개로 갈렸습니다.")
|
| 379 |
+
if disagreement > policy["max_disagreement"]:
|
| 380 |
+
reasons.append("모델 간 확률 차이가 큽니다.")
|
| 381 |
+
|
| 382 |
+
if verdict == "fake":
|
| 383 |
+
confidence = min(0.99, 0.50 + (weighted_fake - 0.50) * 1.55)
|
| 384 |
+
label_ko = "AI 생성 이미지"
|
| 385 |
+
reliability = "높음" if confidence >= 0.82 else "중간"
|
| 386 |
+
elif verdict == "real":
|
| 387 |
+
confidence = min(0.99, 0.50 + (0.50 - weighted_fake) * 1.55)
|
| 388 |
+
label_ko = "실제 사진"
|
| 389 |
+
reliability = "높음" if confidence >= 0.82 else "중간"
|
| 390 |
+
else:
|
| 391 |
+
confidence = max(0.50, 1.0 - abs(weighted_fake - 0.50) * 1.6 - min(disagreement, 0.5) * 0.45)
|
| 392 |
+
label_ko = "검토 필요"
|
| 393 |
+
reliability = "보류"
|
| 394 |
+
|
| 395 |
+
return {
|
| 396 |
+
"verdict": verdict,
|
| 397 |
+
"prediction": {"fake": "AI", "real": "REAL", "review": "REVIEW"}[verdict],
|
| 398 |
+
"label_ko": label_ko,
|
| 399 |
+
"weighted_fake_prob": round(weighted_fake, 6),
|
| 400 |
+
"fake_prob": round(weighted_fake, 6),
|
| 401 |
+
"real_prob": round(1.0 - weighted_fake, 6),
|
| 402 |
+
"confidence": round(float(confidence), 4),
|
| 403 |
+
"fake_votes": fake_votes,
|
| 404 |
+
"real_votes": real_votes,
|
| 405 |
+
"disagreement": round(float(disagreement), 6),
|
| 406 |
+
"mode": policy["mode"],
|
| 407 |
+
"policy": policy,
|
| 408 |
+
"reliability": reliability,
|
| 409 |
+
"reason_ko": " ".join(reasons),
|
| 410 |
+
"production_note_ko": (
|
| 411 |
+
"배포용 판정은 오판을 줄이기 위해 애매한 이미지를 검토 필요로 돌립니다. "
|
| 412 |
+
"무조건 real/fake로 찍는 데모 모드보다 사용자 신뢰에 유리합니다."
|
| 413 |
+
),
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def predict_image(image: Image.Image, mode: str = "production") -> dict:
|
| 418 |
+
start = time.perf_counter()
|
| 419 |
+
state = load_ensemble()
|
| 420 |
+
rows = [_predict_one(image, model_state) for model_state in state.models]
|
| 421 |
+
decision = _decide(rows, mode)
|
| 422 |
+
return {
|
| 423 |
+
"ok": True,
|
| 424 |
+
**decision,
|
| 425 |
+
"models": rows,
|
| 426 |
+
"model_probs": rows,
|
| 427 |
+
"model": {
|
| 428 |
+
"kind": "ensemble",
|
| 429 |
+
"loaded_at": state.loaded_at,
|
| 430 |
+
"ids": [m.model_id for m in state.models],
|
| 431 |
+
"device": str(DEVICE),
|
| 432 |
+
},
|
| 433 |
+
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
| 434 |
+
"api_version": "production-ensemble-1.0",
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _xai_model(preferred: str | None = None) -> ModelState:
|
| 439 |
+
state = load_ensemble()
|
| 440 |
+
preferred = preferred or os.environ.get("A_EYE_XAI_MODEL", "32")
|
| 441 |
+
for model_state in state.models:
|
| 442 |
+
if model_state.model_id == preferred:
|
| 443 |
+
return model_state
|
| 444 |
+
return state.models[0]
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def explain_payload(image: Image.Image, preferred_model: str | None = None) -> dict:
|
| 448 |
+
from src.xai import explain_image, png_b64
|
| 449 |
+
|
| 450 |
+
model_state = _xai_model(preferred_model)
|
| 451 |
+
result = explain_image(
|
| 452 |
+
model_state.model,
|
| 453 |
+
model_state.transform,
|
| 454 |
+
DEVICE,
|
| 455 |
+
image,
|
| 456 |
+
target_class=1,
|
| 457 |
+
expansion=1.30,
|
| 458 |
+
)
|
| 459 |
+
overlay_b64 = png_b64(result.overlay)
|
| 460 |
+
heatmap_b64 = png_b64(result.heatmap)
|
| 461 |
+
return {
|
| 462 |
+
"ok": True,
|
| 463 |
+
"xai_model": model_state.model_id,
|
| 464 |
+
"xai_model_name": model_state.exp_name,
|
| 465 |
+
"xai_method": result.meta.get("method", "occlusion_vit_srm_inputgrad"),
|
| 466 |
+
"xai": result.meta,
|
| 467 |
+
"overlay_b64": overlay_b64,
|
| 468 |
+
"heatmap_b64": heatmap_b64,
|
| 469 |
+
"overlay_data_url": f"data:image/png;base64,{overlay_b64}",
|
| 470 |
+
"heatmap_data_url": f"data:image/png;base64,{heatmap_b64}",
|
| 471 |
+
"xai_note_ko": (
|
| 472 |
+
"히트맵은 모델이 AI 판정에 사용한 증거 영역입니다. "
|
| 473 |
+
"합성된 부분을 정확히 잘라내는 마스크가 아니라 판단 근거 지도입니다."
|
| 474 |
+
),
|
| 475 |
+
"xai_limitation_ko": (
|
| 476 |
+
"고양이, 얼굴, 물체 전체를 정확히 칠하려면 객체 분할 모델과 조작 위치 라벨이 별도로 필요합니다."
|
| 477 |
+
),
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def save_feedback(image_bytes: bytes, original_name: str, true_label: str, metadata: dict) -> dict:
|
| 482 |
+
label = true_label if true_label in {"real", "fake", "uncertain"} else "uncertain"
|
| 483 |
+
bucket = FEEDBACK_DIR / label
|
| 484 |
+
bucket.mkdir(parents=True, exist_ok=True)
|
| 485 |
+
digest = sha1(image_bytes).hexdigest()
|
| 486 |
+
suffix = Path(original_name).suffix.lower()
|
| 487 |
+
if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
|
| 488 |
+
suffix = ".jpg"
|
| 489 |
+
image_path = bucket / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{digest[:12]}{suffix}"
|
| 490 |
+
image_path.write_bytes(image_bytes)
|
| 491 |
+
record = {
|
| 492 |
+
"saved_at": datetime.now().isoformat(timespec="seconds"),
|
| 493 |
+
"image": str(image_path),
|
| 494 |
+
"sha1": digest,
|
| 495 |
+
"true_label": label,
|
| 496 |
+
**metadata,
|
| 497 |
+
}
|
| 498 |
+
with (FEEDBACK_DIR / "feedback.jsonl").open("a", encoding="utf-8") as f:
|
| 499 |
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 500 |
+
return {"bucket": label, "path": str(image_path), "sha1": digest}
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _status_payload() -> dict:
|
| 504 |
+
state = load_ensemble()
|
| 505 |
+
return {
|
| 506 |
+
"ok": True,
|
| 507 |
+
"version": "production-ensemble-1.0",
|
| 508 |
+
"api_version": "production-ensemble-1.0",
|
| 509 |
+
"device": str(DEVICE),
|
| 510 |
+
"xai": True,
|
| 511 |
+
"loaded_at": state.loaded_at,
|
| 512 |
+
"models": [
|
| 513 |
+
{
|
| 514 |
+
"id": m.model_id,
|
| 515 |
+
"exp_name": m.exp_name,
|
| 516 |
+
"label": m.label,
|
| 517 |
+
"role": m.role,
|
| 518 |
+
"weight": round(m.weight, 4),
|
| 519 |
+
"threshold": round(m.threshold, 4),
|
| 520 |
+
"checkpoint": str(m.checkpoint),
|
| 521 |
+
}
|
| 522 |
+
for m in state.models
|
| 523 |
+
],
|
| 524 |
+
"default_mode": "production",
|
| 525 |
+
"modes": ["production", "real_guard", "sensitive"],
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
@app.after_request
|
| 530 |
+
def add_cors(response):
|
| 531 |
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
| 532 |
+
response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
|
| 533 |
+
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization"
|
| 534 |
+
return response
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
HTML = """
|
| 538 |
+
<!doctype html>
|
| 539 |
+
<html lang="ko">
|
| 540 |
+
<head>
|
| 541 |
+
<meta charset="utf-8">
|
| 542 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 543 |
+
<title>A-EYE Production Ensemble</title>
|
| 544 |
+
<style>
|
| 545 |
+
:root { color-scheme: dark; --bg:#08111f; --panel:#101b2c; --line:#24344d; --text:#e8f0ff; --muted:#8fa3bf; --blue:#63a7ff; --red:#ff6b6b; --green:#48d597; --yellow:#f3c969; }
|
| 546 |
+
* { box-sizing:border-box; }
|
| 547 |
+
body { margin:0; font-family:system-ui,-apple-system,Segoe UI,sans-serif; background:var(--bg); color:var(--text); }
|
| 548 |
+
main { max-width:1120px; margin:0 auto; padding:28px 18px 48px; }
|
| 549 |
+
header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:22px; }
|
| 550 |
+
h1 { margin:0; font-size:26px; letter-spacing:0; }
|
| 551 |
+
.sub { color:var(--muted); margin-top:8px; line-height:1.5; }
|
| 552 |
+
.grid { display:grid; grid-template-columns:360px 1fr; gap:18px; align-items:start; }
|
| 553 |
+
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; }
|
| 554 |
+
label { display:block; color:var(--muted); font-size:13px; margin-bottom:8px; }
|
| 555 |
+
input, select, button { width:100%; border-radius:8px; border:1px solid var(--line); background:#07101d; color:var(--text); padding:11px 12px; font-size:15px; }
|
| 556 |
+
button { cursor:pointer; background:#12345d; border-color:#2e5f99; font-weight:700; }
|
| 557 |
+
button:disabled { opacity:.55; cursor:not-allowed; }
|
| 558 |
+
.preview { width:100%; aspect-ratio:1/1; object-fit:contain; background:#050b13; border:1px solid var(--line); border-radius:8px; margin-top:12px; }
|
| 559 |
+
.verdict { font-size:32px; font-weight:800; margin:0 0 8px; }
|
| 560 |
+
.fake { color:var(--red); } .real { color:var(--green); } .review { color:var(--yellow); }
|
| 561 |
+
.metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }
|
| 562 |
+
.metric { border:1px solid var(--line); border-radius:8px; padding:10px; background:#07101d; }
|
| 563 |
+
.metric b { display:block; font-size:20px; margin-top:4px; }
|
| 564 |
+
table { width:100%; border-collapse:collapse; margin-top:12px; font-size:14px; }
|
| 565 |
+
th, td { border-bottom:1px solid var(--line); padding:9px 6px; text-align:left; }
|
| 566 |
+
th { color:var(--muted); font-weight:600; }
|
| 567 |
+
.xai { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; }
|
| 568 |
+
.xai img { width:100%; border:1px solid var(--line); border-radius:8px; background:#050b13; }
|
| 569 |
+
.note { color:var(--muted); line-height:1.55; }
|
| 570 |
+
@media (max-width:860px) { .grid { grid-template-columns:1fr; } .metrics { grid-template-columns:1fr 1fr; } header { display:block; } }
|
| 571 |
+
</style>
|
| 572 |
+
</head>
|
| 573 |
+
<body>
|
| 574 |
+
<main>
|
| 575 |
+
<header>
|
| 576 |
+
<div>
|
| 577 |
+
<h1>A-EYE 실서비스 판정 서버</h1>
|
| 578 |
+
<div class="sub">32/37/34/36 모델 앙상블로 실제사진 오탐과 AI 미탐을 동시에 줄이고, 애매한 이미지는 검토 필요로 분리합니다.</div>
|
| 579 |
+
</div>
|
| 580 |
+
<div class="sub" id="status">서버 상태 확인 중</div>
|
| 581 |
+
</header>
|
| 582 |
+
<div class="grid">
|
| 583 |
+
<section class="panel">
|
| 584 |
+
<label>이미지 업로드</label>
|
| 585 |
+
<input id="file" type="file" accept="image/*">
|
| 586 |
+
<img id="preview" class="preview" alt="">
|
| 587 |
+
<label style="margin-top:14px;">판정 모드</label>
|
| 588 |
+
<select id="mode">
|
| 589 |
+
<option value="production">실서비스 균형</option>
|
| 590 |
+
<option value="real_guard">실제사진 보호 우선</option>
|
| 591 |
+
<option value="sensitive">AI 탐지 민감</option>
|
| 592 |
+
</select>
|
| 593 |
+
<button id="run" style="margin-top:14px;">분석하기</button>
|
| 594 |
+
<div class="sub" style="margin-top:12px;">앱에서는 /api/predict, /api/health, /api/version을 사용하면 됩니다.</div>
|
| 595 |
+
</section>
|
| 596 |
+
<section class="panel">
|
| 597 |
+
<p id="label" class="verdict review">대기 중</p>
|
| 598 |
+
<div id="reason" class="note">이미지를 넣으면 앙상블 판정과 XAI가 표시됩니다.</div>
|
| 599 |
+
<div class="metrics">
|
| 600 |
+
<div class="metric"><span class="note">AI 확률</span><b id="fakeProb">-</b></div>
|
| 601 |
+
<div class="metric"><span class="note">실제 확률</span><b id="realProb">-</b></div>
|
| 602 |
+
<div class="metric"><span class="note">신뢰도</span><b id="conf">-</b></div>
|
| 603 |
+
<div class="metric"><span class="note">모델 차이</span><b id="disagree">-</b></div>
|
| 604 |
+
</div>
|
| 605 |
+
<table>
|
| 606 |
+
<thead><tr><th>모델</th><th>역할</th><th>AI 확률</th><th>투표</th></tr></thead>
|
| 607 |
+
<tbody id="modelRows"></tbody>
|
| 608 |
+
</table>
|
| 609 |
+
<div class="xai" id="xaiBox" style="display:none;">
|
| 610 |
+
<div><div class="note">원본 위 의심 영역</div><img id="overlay" alt=""></div>
|
| 611 |
+
<div><div class="note">히트맵</div><img id="heatmap" alt=""></div>
|
| 612 |
+
</div>
|
| 613 |
+
<div class="note" id="xaiNote" style="margin-top:10px;"></div>
|
| 614 |
+
</section>
|
| 615 |
+
</div>
|
| 616 |
+
</main>
|
| 617 |
+
<script>
|
| 618 |
+
const file = document.getElementById("file");
|
| 619 |
+
const preview = document.getElementById("preview");
|
| 620 |
+
const run = document.getElementById("run");
|
| 621 |
+
let selected = null;
|
| 622 |
+
const pct = v => `${(Number(v || 0) * 100).toFixed(2)}%`;
|
| 623 |
+
file.addEventListener("change", () => {
|
| 624 |
+
selected = file.files[0] || null;
|
| 625 |
+
preview.src = selected ? URL.createObjectURL(selected) : "";
|
| 626 |
+
});
|
| 627 |
+
async function loadStatus() {
|
| 628 |
+
const res = await fetch("/api/health");
|
| 629 |
+
const data = await res.json();
|
| 630 |
+
document.getElementById("status").textContent = data.ok ? `${data.device} · ${data.models.map(m => m.id).join("+")}` : data.error;
|
| 631 |
+
}
|
| 632 |
+
run.addEventListener("click", async () => {
|
| 633 |
+
if (!selected) return;
|
| 634 |
+
run.disabled = true;
|
| 635 |
+
const form = new FormData();
|
| 636 |
+
form.append("image", selected);
|
| 637 |
+
form.append("mode", document.getElementById("mode").value);
|
| 638 |
+
form.append("xai", "1");
|
| 639 |
+
try {
|
| 640 |
+
const res = await fetch("/api/predict", { method:"POST", body:form });
|
| 641 |
+
const data = await res.json();
|
| 642 |
+
if (!res.ok || !data.ok) throw new Error(data.error || "분석 실패");
|
| 643 |
+
const label = document.getElementById("label");
|
| 644 |
+
label.textContent = data.label_ko;
|
| 645 |
+
label.className = `verdict ${data.verdict}`;
|
| 646 |
+
document.getElementById("reason").textContent = data.reason_ko;
|
| 647 |
+
document.getElementById("fakeProb").textContent = pct(data.fake_prob);
|
| 648 |
+
document.getElementById("realProb").textContent = pct(data.real_prob);
|
| 649 |
+
document.getElementById("conf").textContent = pct(data.confidence);
|
| 650 |
+
document.getElementById("disagree").textContent = pct(data.disagreement);
|
| 651 |
+
document.getElementById("modelRows").innerHTML = data.models.map(m => `<tr><td>${m.id}</td><td>${m.role}</td><td>${pct(m.fake_prob)}</td><td>${m.vote === "fake" ? "AI" : "실제"}</td></tr>`).join("");
|
| 652 |
+
if (data.overlay_data_url) {
|
| 653 |
+
document.getElementById("overlay").src = data.overlay_data_url;
|
| 654 |
+
document.getElementById("heatmap").src = data.heatmap_data_url;
|
| 655 |
+
document.getElementById("xaiBox").style.display = "grid";
|
| 656 |
+
document.getElementById("xaiNote").textContent = data.xai_note_ko || "";
|
| 657 |
+
}
|
| 658 |
+
} catch (err) {
|
| 659 |
+
document.getElementById("label").textContent = "오류";
|
| 660 |
+
document.getElementById("reason").textContent = err.message;
|
| 661 |
+
} finally {
|
| 662 |
+
run.disabled = false;
|
| 663 |
+
}
|
| 664 |
+
});
|
| 665 |
+
loadStatus().catch(err => document.getElementById("status").textContent = err.message);
|
| 666 |
+
</script>
|
| 667 |
+
</body>
|
| 668 |
+
</html>
|
| 669 |
+
"""
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
@app.get("/")
|
| 673 |
+
def index():
|
| 674 |
+
return render_template_string(HTML)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
@app.get("/health")
|
| 678 |
+
@app.get("/api/health")
|
| 679 |
+
@app.get("/status")
|
| 680 |
+
@app.get("/api/status")
|
| 681 |
+
def health_route():
|
| 682 |
+
try:
|
| 683 |
+
return jsonify(_status_payload())
|
| 684 |
+
except Exception as exc:
|
| 685 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
@app.get("/version")
|
| 689 |
+
@app.get("/api/version")
|
| 690 |
+
def version_route():
|
| 691 |
+
try:
|
| 692 |
+
return jsonify(_status_payload())
|
| 693 |
+
except Exception as exc:
|
| 694 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
@app.post("/reload")
|
| 698 |
+
@app.post("/api/reload")
|
| 699 |
+
def reload_route():
|
| 700 |
+
try:
|
| 701 |
+
load_ensemble(force=True)
|
| 702 |
+
return jsonify(_status_payload())
|
| 703 |
+
except Exception as exc:
|
| 704 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
@app.route("/predict", methods=["POST", "OPTIONS"])
|
| 708 |
+
@app.route("/api/predict", methods=["POST", "OPTIONS"])
|
| 709 |
+
@app.route("/analyze", methods=["POST", "OPTIONS"])
|
| 710 |
+
@app.route("/api/analyze", methods=["POST", "OPTIONS"])
|
| 711 |
+
@app.route("/classify", methods=["POST", "OPTIONS"])
|
| 712 |
+
@app.route("/api/classify", methods=["POST", "OPTIONS"])
|
| 713 |
+
@app.route("/detect", methods=["POST", "OPTIONS"])
|
| 714 |
+
@app.route("/api/detect", methods=["POST", "OPTIONS"])
|
| 715 |
+
def predict_route():
|
| 716 |
+
if request.method == "OPTIONS":
|
| 717 |
+
return ("", 204)
|
| 718 |
+
upload = request.files.get("image") or request.files.get("file")
|
| 719 |
+
if upload is None or not upload.filename:
|
| 720 |
+
return jsonify({"ok": False, "error": "image file is required"}), 400
|
| 721 |
+
raw = upload.read()
|
| 722 |
+
if not raw:
|
| 723 |
+
return jsonify({"ok": False, "error": "empty image"}), 400
|
| 724 |
+
try:
|
| 725 |
+
image = Image.open(io.BytesIO(raw))
|
| 726 |
+
payload = predict_image(image, mode=request.form.get("mode", "production"))
|
| 727 |
+
include_xai = request.form.get("xai", request.args.get("xai", "1")).strip().lower()
|
| 728 |
+
if include_xai not in {"0", "false", "no"}:
|
| 729 |
+
payload.update(explain_payload(image, request.form.get("xai_model")))
|
| 730 |
+
return jsonify(payload)
|
| 731 |
+
except Exception as exc:
|
| 732 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
@app.route("/xai", methods=["POST", "OPTIONS"])
|
| 736 |
+
@app.route("/api/xai", methods=["POST", "OPTIONS"])
|
| 737 |
+
@app.route("/explain", methods=["POST", "OPTIONS"])
|
| 738 |
+
@app.route("/api/explain", methods=["POST", "OPTIONS"])
|
| 739 |
+
def xai_route():
|
| 740 |
+
if request.method == "OPTIONS":
|
| 741 |
+
return ("", 204)
|
| 742 |
+
upload = request.files.get("image") or request.files.get("file")
|
| 743 |
+
if upload is None or not upload.filename:
|
| 744 |
+
return jsonify({"ok": False, "error": "image file is required"}), 400
|
| 745 |
+
raw = upload.read()
|
| 746 |
+
if not raw:
|
| 747 |
+
return jsonify({"ok": False, "error": "empty image"}), 400
|
| 748 |
+
try:
|
| 749 |
+
return jsonify(explain_payload(Image.open(io.BytesIO(raw)), request.form.get("xai_model")))
|
| 750 |
+
except Exception as exc:
|
| 751 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
@app.route("/feedback", methods=["POST", "OPTIONS"])
|
| 755 |
+
@app.route("/api/feedback", methods=["POST", "OPTIONS"])
|
| 756 |
+
def feedback_route():
|
| 757 |
+
if request.method == "OPTIONS":
|
| 758 |
+
return ("", 204)
|
| 759 |
+
upload = request.files.get("image") or request.files.get("file")
|
| 760 |
+
if upload is None or not upload.filename:
|
| 761 |
+
return jsonify({"ok": False, "error": "image file is required"}), 400
|
| 762 |
+
image_bytes = upload.read()
|
| 763 |
+
if not image_bytes:
|
| 764 |
+
return jsonify({"ok": False, "error": "empty image"}), 400
|
| 765 |
+
metadata = {
|
| 766 |
+
"predicted_label": request.form.get("predicted_label"),
|
| 767 |
+
"fake_prob": request.form.get("fake_prob"),
|
| 768 |
+
"real_prob": request.form.get("real_prob"),
|
| 769 |
+
"mode": request.form.get("mode"),
|
| 770 |
+
"models": request.form.get("models"),
|
| 771 |
+
}
|
| 772 |
+
try:
|
| 773 |
+
saved = save_feedback(image_bytes, upload.filename, request.form.get("true_label", "uncertain"), metadata)
|
| 774 |
+
return jsonify({"ok": True, **saved})
|
| 775 |
+
except Exception as exc:
|
| 776 |
+
return jsonify({"ok": False, "error": str(exc)}), 500
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def main() -> None:
|
| 780 |
+
host = os.environ.get("A_EYE_HOST", "0.0.0.0")
|
| 781 |
+
port = int(os.environ.get("PORT") or os.environ.get("A_EYE_PORT", "8097"))
|
| 782 |
+
load_ensemble()
|
| 783 |
+
print(f"[A-EYE production] open http://127.0.0.1:{port}", flush=True)
|
| 784 |
+
app.run(host=host, port=port, debug=False, use_reloader=False)
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
if __name__ == "__main__":
|
| 788 |
+
main()
|
src/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# TODO: API 구현 완료 후 export 추가
|
src/data/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .dataset import AEyeDataset, build_dataloaders, LABEL_MAP, SUPPORTED_EXTS
|
| 2 |
+
from .transforms import get_transforms, IMAGENET_MEAN, IMAGENET_STD, CLIP_MEAN, CLIP_STD
|
src/data/all_sources_dataset.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset utilities for training on every labeled local image source."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
from collections import Counter, defaultdict
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Callable, Iterable
|
| 12 |
+
|
| 13 |
+
from PIL import Image, UnidentifiedImageError
|
| 14 |
+
import torch
|
| 15 |
+
from torch.utils.data import Dataset, WeightedRandomSampler
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
|
| 19 |
+
LABEL_NAMES = {0: "real", 1: "fake"}
|
| 20 |
+
FOLDER_LABELS = {"real": 0, "REAL": 0, "fake": 1, "FAKE": 1, "ai": 1, "AI": 1}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class ImageRecord:
|
| 25 |
+
path: str
|
| 26 |
+
label: int
|
| 27 |
+
source: str
|
| 28 |
+
is_hard: bool = False
|
| 29 |
+
sample_weight: float = 1.0
|
| 30 |
+
hard_weight_override: float | None = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _iter_images(folder: Path) -> Iterable[Path]:
|
| 34 |
+
for path in folder.rglob("*"):
|
| 35 |
+
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTS:
|
| 36 |
+
yield path
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _scan_source(source: dict | str | Path) -> list[ImageRecord]:
|
| 40 |
+
if isinstance(source, (str, Path)):
|
| 41 |
+
source_cfg = {"path": str(source)}
|
| 42 |
+
else:
|
| 43 |
+
source_cfg = dict(source)
|
| 44 |
+
|
| 45 |
+
root = Path(source_cfg["path"]).resolve()
|
| 46 |
+
source_name = source_cfg.get("name", root.name)
|
| 47 |
+
is_hard = bool(source_cfg.get("hard", False))
|
| 48 |
+
sample_weight = float(source_cfg.get("sample_weight", source_cfg.get("weight", 1.0)))
|
| 49 |
+
hard_weight_override = source_cfg.get("hard_weight")
|
| 50 |
+
if hard_weight_override is not None:
|
| 51 |
+
hard_weight_override = float(hard_weight_override)
|
| 52 |
+
if not root.exists():
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
records: list[ImageRecord] = []
|
| 56 |
+
root_label = FOLDER_LABELS.get(root.name)
|
| 57 |
+
if root_label is not None:
|
| 58 |
+
for image_path in _iter_images(root):
|
| 59 |
+
records.append(ImageRecord(
|
| 60 |
+
str(image_path),
|
| 61 |
+
root_label,
|
| 62 |
+
source_name,
|
| 63 |
+
is_hard,
|
| 64 |
+
sample_weight,
|
| 65 |
+
hard_weight_override,
|
| 66 |
+
))
|
| 67 |
+
return records
|
| 68 |
+
|
| 69 |
+
for child in sorted(root.iterdir()):
|
| 70 |
+
if not child.is_dir():
|
| 71 |
+
continue
|
| 72 |
+
label = FOLDER_LABELS.get(child.name)
|
| 73 |
+
if label is None:
|
| 74 |
+
continue
|
| 75 |
+
for image_path in _iter_images(child):
|
| 76 |
+
records.append(ImageRecord(
|
| 77 |
+
str(image_path),
|
| 78 |
+
label,
|
| 79 |
+
source_name,
|
| 80 |
+
is_hard,
|
| 81 |
+
sample_weight,
|
| 82 |
+
hard_weight_override,
|
| 83 |
+
))
|
| 84 |
+
return records
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def collect_labeled_records(sources: list[dict | str | Path]) -> list[ImageRecord]:
|
| 88 |
+
records: list[ImageRecord] = []
|
| 89 |
+
for source in sources:
|
| 90 |
+
records.extend(_scan_source(source))
|
| 91 |
+
return records
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _sha1(path: str, chunk_size: int = 1024 * 1024) -> str:
|
| 95 |
+
h = hashlib.sha1()
|
| 96 |
+
with open(path, "rb") as f:
|
| 97 |
+
while True:
|
| 98 |
+
chunk = f.read(chunk_size)
|
| 99 |
+
if not chunk:
|
| 100 |
+
break
|
| 101 |
+
h.update(chunk)
|
| 102 |
+
return h.hexdigest()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def dedupe_records(
|
| 106 |
+
records: list[ImageRecord],
|
| 107 |
+
full_sha1: bool = False,
|
| 108 |
+
) -> tuple[list[ImageRecord], dict]:
|
| 109 |
+
"""Remove duplicate image files and drop label conflicts.
|
| 110 |
+
|
| 111 |
+
By default this hashes only files that share the same filename and size. It
|
| 112 |
+
is much faster for a 600k-image local pool while catching the common duplicate
|
| 113 |
+
and UI-feedback conflict cases. Set full_sha1=True for a slower exact pass.
|
| 114 |
+
"""
|
| 115 |
+
if full_sha1:
|
| 116 |
+
rough_groups: dict[tuple[str, int], list[ImageRecord]] = {("all", 0): records}
|
| 117 |
+
else:
|
| 118 |
+
rough_groups = defaultdict(list)
|
| 119 |
+
for record in records:
|
| 120 |
+
try:
|
| 121 |
+
stat = Path(record.path).stat()
|
| 122 |
+
key = (Path(record.path).name.lower(), stat.st_size)
|
| 123 |
+
except OSError:
|
| 124 |
+
key = (Path(record.path).name.lower(), -1)
|
| 125 |
+
rough_groups[key].append(record)
|
| 126 |
+
|
| 127 |
+
kept: list[ImageRecord] = []
|
| 128 |
+
conflicts: list[dict] = []
|
| 129 |
+
duplicate_count = 0
|
| 130 |
+
hashed_files = 0
|
| 131 |
+
total_to_hash = len(records) if full_sha1 else sum(
|
| 132 |
+
len(group) for group in rough_groups.values() if len(group) > 1
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
for group in rough_groups.values():
|
| 136 |
+
if len(group) == 1 and not full_sha1:
|
| 137 |
+
kept.append(group[0])
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
by_hash: dict[str, list[ImageRecord]] = defaultdict(list)
|
| 141 |
+
for record in group:
|
| 142 |
+
try:
|
| 143 |
+
digest = _sha1(record.path)
|
| 144 |
+
hashed_files += 1
|
| 145 |
+
if total_to_hash and hashed_files % 10000 == 0:
|
| 146 |
+
print(
|
| 147 |
+
f"[data] sha1 dedupe hashed {hashed_files:,}/{total_to_hash:,} files",
|
| 148 |
+
flush=True,
|
| 149 |
+
)
|
| 150 |
+
except OSError:
|
| 151 |
+
digest = f"missing::{record.path}"
|
| 152 |
+
by_hash[digest].append(record)
|
| 153 |
+
|
| 154 |
+
for digest, same_files in by_hash.items():
|
| 155 |
+
labels = {item.label for item in same_files}
|
| 156 |
+
if len(labels) > 1:
|
| 157 |
+
conflicts.append({
|
| 158 |
+
"sha1": digest,
|
| 159 |
+
"labels": sorted(labels),
|
| 160 |
+
"paths": [item.path for item in same_files[:8]],
|
| 161 |
+
"count": len(same_files),
|
| 162 |
+
})
|
| 163 |
+
duplicate_count += len(same_files)
|
| 164 |
+
continue
|
| 165 |
+
kept.append(same_files[0])
|
| 166 |
+
duplicate_count += max(0, len(same_files) - 1)
|
| 167 |
+
|
| 168 |
+
report = {
|
| 169 |
+
"input_count": len(records),
|
| 170 |
+
"kept_count": len(kept),
|
| 171 |
+
"duplicate_or_conflict_removed": duplicate_count,
|
| 172 |
+
"conflict_count": len(conflicts),
|
| 173 |
+
"conflicts_preview": conflicts[:20],
|
| 174 |
+
"hashed_files": hashed_files,
|
| 175 |
+
"full_sha1": full_sha1,
|
| 176 |
+
}
|
| 177 |
+
return kept, report
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def split_records(
|
| 181 |
+
records: list[ImageRecord],
|
| 182 |
+
val_ratio: float = 0.08,
|
| 183 |
+
seed: int = 42,
|
| 184 |
+
force_hard_to_train: bool = True,
|
| 185 |
+
) -> tuple[list[ImageRecord], list[ImageRecord], dict]:
|
| 186 |
+
rng = random.Random(seed)
|
| 187 |
+
train: list[ImageRecord] = []
|
| 188 |
+
val: list[ImageRecord] = []
|
| 189 |
+
per_class: dict[int, list[ImageRecord]] = defaultdict(list)
|
| 190 |
+
|
| 191 |
+
for record in records:
|
| 192 |
+
if force_hard_to_train and record.is_hard:
|
| 193 |
+
train.append(record)
|
| 194 |
+
else:
|
| 195 |
+
per_class[record.label].append(record)
|
| 196 |
+
|
| 197 |
+
for label_records in per_class.values():
|
| 198 |
+
rng.shuffle(label_records)
|
| 199 |
+
n_val = max(1, int(len(label_records) * val_ratio))
|
| 200 |
+
val.extend(label_records[:n_val])
|
| 201 |
+
train.extend(label_records[n_val:])
|
| 202 |
+
|
| 203 |
+
rng.shuffle(train)
|
| 204 |
+
rng.shuffle(val)
|
| 205 |
+
|
| 206 |
+
report = {
|
| 207 |
+
"train_count": len(train),
|
| 208 |
+
"val_count": len(val),
|
| 209 |
+
"force_hard_to_train": force_hard_to_train,
|
| 210 |
+
"train_counts": _count_records(train),
|
| 211 |
+
"val_counts": _count_records(val),
|
| 212 |
+
}
|
| 213 |
+
return train, val, report
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _count_records(records: list[ImageRecord]) -> dict:
|
| 217 |
+
labels = Counter(record.label for record in records)
|
| 218 |
+
sources = Counter(record.source for record in records)
|
| 219 |
+
hard = Counter(record.label for record in records if record.is_hard)
|
| 220 |
+
weighted_sources = {
|
| 221 |
+
source: sorted({
|
| 222 |
+
float(record.hard_weight_override)
|
| 223 |
+
for record in records
|
| 224 |
+
if record.source == source and record.hard_weight_override is not None
|
| 225 |
+
})
|
| 226 |
+
for source in sources
|
| 227 |
+
}
|
| 228 |
+
return {
|
| 229 |
+
"real": labels.get(0, 0),
|
| 230 |
+
"fake": labels.get(1, 0),
|
| 231 |
+
"hard_real": hard.get(0, 0),
|
| 232 |
+
"hard_fake": hard.get(1, 0),
|
| 233 |
+
"sources": dict(sorted(sources.items())),
|
| 234 |
+
"hard_weight_overrides": {
|
| 235 |
+
source: values for source, values in sorted(weighted_sources.items()) if values
|
| 236 |
+
},
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class AllSourcesDataset(Dataset):
|
| 241 |
+
def __init__(
|
| 242 |
+
self,
|
| 243 |
+
records: list[ImageRecord],
|
| 244 |
+
transform: Callable | None = None,
|
| 245 |
+
return_group: bool = False,
|
| 246 |
+
):
|
| 247 |
+
if not records:
|
| 248 |
+
raise RuntimeError("No image records were found.")
|
| 249 |
+
self.records = records
|
| 250 |
+
self.transform = transform
|
| 251 |
+
self.return_group = bool(return_group)
|
| 252 |
+
self.group_to_id = {
|
| 253 |
+
source: idx for idx, source in enumerate(sorted({record.source for record in records}))
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
def __len__(self) -> int:
|
| 257 |
+
return len(self.records)
|
| 258 |
+
|
| 259 |
+
def __getitem__(self, idx: int):
|
| 260 |
+
last_error: Exception | None = None
|
| 261 |
+
for offset in range(12):
|
| 262 |
+
record = self.records[(idx + offset) % len(self.records)]
|
| 263 |
+
try:
|
| 264 |
+
image = Image.open(record.path).convert("RGB")
|
| 265 |
+
if self.transform is not None:
|
| 266 |
+
image = self.transform(image)
|
| 267 |
+
if self.return_group:
|
| 268 |
+
return image, record.label, self.group_to_id.get(record.source, 0)
|
| 269 |
+
return image, record.label
|
| 270 |
+
except (UnidentifiedImageError, OSError, RuntimeError) as exc:
|
| 271 |
+
last_error = exc
|
| 272 |
+
raise RuntimeError(f"Could not load image near index {idx}") from last_error
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def make_weighted_sampler(
|
| 276 |
+
records: list[ImageRecord],
|
| 277 |
+
hard_weight: float = 500.0,
|
| 278 |
+
source_balance_alpha: float = 0.0,
|
| 279 |
+
max_source_weight: float = 8.0,
|
| 280 |
+
num_samples: int | None = None,
|
| 281 |
+
) -> WeightedRandomSampler:
|
| 282 |
+
counts = Counter(record.label for record in records)
|
| 283 |
+
source_label_counts = Counter((record.source, record.label) for record in records)
|
| 284 |
+
total = len(records)
|
| 285 |
+
source_groups = max(1, len(source_label_counts))
|
| 286 |
+
weights = []
|
| 287 |
+
for record in records:
|
| 288 |
+
class_weight = total / max(1, 2 * counts[record.label])
|
| 289 |
+
source_weight = 1.0
|
| 290 |
+
if source_balance_alpha > 0:
|
| 291 |
+
source_count = source_label_counts[(record.source, record.label)]
|
| 292 |
+
raw_source_weight = total / max(1, source_groups * source_count)
|
| 293 |
+
source_weight = min(max_source_weight, raw_source_weight ** source_balance_alpha)
|
| 294 |
+
hard_multiplier = 1.0
|
| 295 |
+
if record.is_hard:
|
| 296 |
+
hard_multiplier = (
|
| 297 |
+
record.hard_weight_override
|
| 298 |
+
if record.hard_weight_override is not None
|
| 299 |
+
else hard_weight
|
| 300 |
+
)
|
| 301 |
+
weight = class_weight * source_weight * hard_multiplier * max(0.0, record.sample_weight)
|
| 302 |
+
weights.append(weight)
|
| 303 |
+
return WeightedRandomSampler(
|
| 304 |
+
weights=torch.DoubleTensor(weights),
|
| 305 |
+
num_samples=num_samples or len(records),
|
| 306 |
+
replacement=True,
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def save_records(path: str | Path, records: list[ImageRecord]) -> None:
|
| 311 |
+
Path(path).write_text(
|
| 312 |
+
json.dumps([asdict(record) for record in records], ensure_ascii=False, indent=2),
|
| 313 |
+
encoding="utf-8",
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def save_split_report(path: str | Path, report: dict) -> None:
|
| 318 |
+
Path(path).write_text(
|
| 319 |
+
json.dumps(report, ensure_ascii=False, indent=2),
|
| 320 |
+
encoding="utf-8",
|
| 321 |
+
)
|
src/data/artifact_transforms.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Robust train/eval transforms for practical AI-image detection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
| 9 |
+
from torchvision import transforms
|
| 10 |
+
from torchvision.transforms import InterpolationMode
|
| 11 |
+
|
| 12 |
+
from .transforms import CLIP_MEAN, CLIP_STD, DownsampleUpsample, GaussianNoise, JPEGCompression
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class RandomUnsharpMask:
|
| 16 |
+
"""Mild sharpening to mimic phone/editor export pipelines."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, radius_range=(0.8, 1.4), percent_range=(80, 160), threshold=3):
|
| 19 |
+
self.radius_range = radius_range
|
| 20 |
+
self.percent_range = percent_range
|
| 21 |
+
self.threshold = threshold
|
| 22 |
+
|
| 23 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 24 |
+
radius = random.uniform(*self.radius_range)
|
| 25 |
+
percent = random.randint(*self.percent_range)
|
| 26 |
+
return img.filter(ImageFilter.UnsharpMask(radius=radius, percent=percent, threshold=self.threshold))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RandomGamma:
|
| 30 |
+
"""Small gamma shift on tensor images in [0, 1]."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, gamma_range=(0.88, 1.14)):
|
| 33 |
+
self.gamma_range = gamma_range
|
| 34 |
+
|
| 35 |
+
def __call__(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 36 |
+
gamma = random.uniform(*self.gamma_range)
|
| 37 |
+
return torch.clamp(tensor, 0.0, 1.0).pow(gamma)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class RandomExposureDown:
|
| 41 |
+
"""Low-light exposure drop to reduce dark-real false positives."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, factor_range=(0.24, 0.85)):
|
| 44 |
+
self.factor_range = factor_range
|
| 45 |
+
|
| 46 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 47 |
+
factor = random.uniform(*self.factor_range)
|
| 48 |
+
return ImageEnhance.Brightness(img).enhance(factor)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class RandomColorTemperature:
|
| 52 |
+
"""Warm/cool indoor lighting shift."""
|
| 53 |
+
|
| 54 |
+
def __init__(self, strength_range=(0.04, 0.18)):
|
| 55 |
+
self.strength_range = strength_range
|
| 56 |
+
|
| 57 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 58 |
+
strength = random.uniform(*self.strength_range)
|
| 59 |
+
if random.random() < 0.5:
|
| 60 |
+
gains = (1.0 + strength, 1.0, 1.0 - strength * 0.55)
|
| 61 |
+
else:
|
| 62 |
+
gains = (1.0 - strength * 0.55, 1.0, 1.0 + strength)
|
| 63 |
+
channels = img.convert("RGB").split()
|
| 64 |
+
adjusted = [
|
| 65 |
+
channel.point(lambda value, gain=gain: max(0, min(255, int(value * gain))))
|
| 66 |
+
for channel, gain in zip(channels, gains)
|
| 67 |
+
]
|
| 68 |
+
return Image.merge("RGB", adjusted)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class RandomMotionBlur:
|
| 72 |
+
"""Small horizontal/vertical blur common in phone low-light images."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, sizes=(3, 5)):
|
| 75 |
+
self.sizes = sizes
|
| 76 |
+
|
| 77 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 78 |
+
size = random.choice(self.sizes)
|
| 79 |
+
weights = [0.0] * (size * size)
|
| 80 |
+
if random.random() < 0.5:
|
| 81 |
+
row = size // 2
|
| 82 |
+
for col in range(size):
|
| 83 |
+
weights[row * size + col] = 1.0 / size
|
| 84 |
+
else:
|
| 85 |
+
col = size // 2
|
| 86 |
+
for row in range(size):
|
| 87 |
+
weights[row * size + col] = 1.0 / size
|
| 88 |
+
return img.filter(ImageFilter.Kernel((size, size), weights, scale=1.0))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class RandomShadowCrush:
|
| 92 |
+
"""Crush dark tones while preserving highlights."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, lift_range=(0.00, 0.08), gamma_range=(1.25, 2.10)):
|
| 95 |
+
self.lift_range = lift_range
|
| 96 |
+
self.gamma_range = gamma_range
|
| 97 |
+
|
| 98 |
+
def __call__(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 99 |
+
lift = random.uniform(*self.lift_range)
|
| 100 |
+
gamma = random.uniform(*self.gamma_range)
|
| 101 |
+
x = torch.clamp(tensor - lift, 0.0, 1.0) / max(1e-6, 1.0 - lift)
|
| 102 |
+
return torch.clamp(x.pow(gamma), 0.0, 1.0)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class HighISONoise:
|
| 106 |
+
"""Signal-dependent low-light sensor noise."""
|
| 107 |
+
|
| 108 |
+
def __init__(self, std_range=(0.008, 0.065), chroma_range=(0.004, 0.030)):
|
| 109 |
+
self.std_range = std_range
|
| 110 |
+
self.chroma_range = chroma_range
|
| 111 |
+
|
| 112 |
+
def __call__(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 113 |
+
luminance = tensor.mean(dim=0, keepdim=True).clamp(0.0, 1.0)
|
| 114 |
+
shot_scale = (1.0 - luminance).sqrt()
|
| 115 |
+
gray_noise = torch.randn_like(luminance) * random.uniform(*self.std_range) * shot_scale
|
| 116 |
+
chroma_noise = torch.randn_like(tensor) * random.uniform(*self.chroma_range)
|
| 117 |
+
return torch.clamp(tensor + gray_noise + chroma_noise, 0.0, 1.0)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class RandomProjectorMoire:
|
| 121 |
+
"""Weak stripe pattern from projectors/screens photographed by phones."""
|
| 122 |
+
|
| 123 |
+
def __init__(self, amplitude_range=(0.006, 0.026), period_range=(5.0, 18.0)):
|
| 124 |
+
self.amplitude_range = amplitude_range
|
| 125 |
+
self.period_range = period_range
|
| 126 |
+
|
| 127 |
+
def __call__(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 128 |
+
_, height, width = tensor.shape
|
| 129 |
+
device = tensor.device
|
| 130 |
+
amp = random.uniform(*self.amplitude_range)
|
| 131 |
+
period = random.uniform(*self.period_range)
|
| 132 |
+
phase = random.uniform(0.0, 6.2831853)
|
| 133 |
+
if random.random() < 0.5:
|
| 134 |
+
axis = torch.arange(width, dtype=tensor.dtype, device=device).view(1, 1, width)
|
| 135 |
+
else:
|
| 136 |
+
axis = torch.arange(height, dtype=tensor.dtype, device=device).view(1, height, 1)
|
| 137 |
+
pattern = torch.sin(axis / period + phase) * amp
|
| 138 |
+
return torch.clamp(tensor + pattern, 0.0, 1.0)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def get_artifact_transforms(
|
| 142 |
+
mode: str,
|
| 143 |
+
image_size: int = 224,
|
| 144 |
+
mean: list[float] | None = None,
|
| 145 |
+
std: list[float] | None = None,
|
| 146 |
+
profile: str = "default",
|
| 147 |
+
) -> transforms.Compose:
|
| 148 |
+
mean = mean or CLIP_MEAN
|
| 149 |
+
std = std or CLIP_STD
|
| 150 |
+
normalize = transforms.Normalize(mean=mean, std=std)
|
| 151 |
+
interpolation = InterpolationMode.BICUBIC
|
| 152 |
+
resize_size = int(round(image_size / 0.875))
|
| 153 |
+
|
| 154 |
+
if mode == "train" and profile == "jpeg_invariance":
|
| 155 |
+
return transforms.Compose([
|
| 156 |
+
transforms.RandomResizedCrop(
|
| 157 |
+
image_size,
|
| 158 |
+
scale=(0.72, 1.0),
|
| 159 |
+
ratio=(0.80, 1.25),
|
| 160 |
+
interpolation=interpolation,
|
| 161 |
+
antialias=True,
|
| 162 |
+
),
|
| 163 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 164 |
+
transforms.ColorJitter(brightness=0.12, contrast=0.12, saturation=0.08, hue=0.012),
|
| 165 |
+
transforms.RandomApply([RandomExposureDown(factor_range=(0.34, 0.92))], p=0.22),
|
| 166 |
+
transforms.RandomApply([RandomColorTemperature(strength_range=(0.03, 0.14))], p=0.22),
|
| 167 |
+
transforms.RandomApply([JPEGCompression(quality_range=(70, 95))], p=1.0),
|
| 168 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.55, 0.98))], p=0.24),
|
| 169 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.08, 0.80))], p=0.16),
|
| 170 |
+
transforms.RandomApply([RandomMotionBlur(sizes=(3,))], p=0.08),
|
| 171 |
+
transforms.RandomApply([RandomUnsharpMask()], p=0.10),
|
| 172 |
+
transforms.ToTensor(),
|
| 173 |
+
transforms.RandomApply([RandomGamma(gamma_range=(0.70, 1.40))], p=0.42),
|
| 174 |
+
transforms.RandomApply([HighISONoise(std_range=(0.006, 0.045), chroma_range=(0.003, 0.022))], p=0.20),
|
| 175 |
+
transforms.RandomApply([RandomProjectorMoire(amplitude_range=(0.004, 0.018))], p=0.06),
|
| 176 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.002, 0.016))], p=0.18),
|
| 177 |
+
normalize,
|
| 178 |
+
])
|
| 179 |
+
|
| 180 |
+
if mode == "train" and profile == "practical_balanced":
|
| 181 |
+
return transforms.Compose([
|
| 182 |
+
transforms.RandomResizedCrop(
|
| 183 |
+
image_size,
|
| 184 |
+
scale=(0.78, 1.0),
|
| 185 |
+
ratio=(0.85, 1.18),
|
| 186 |
+
interpolation=interpolation,
|
| 187 |
+
antialias=True,
|
| 188 |
+
),
|
| 189 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 190 |
+
transforms.ColorJitter(brightness=0.10, contrast=0.10, saturation=0.06, hue=0.010),
|
| 191 |
+
transforms.RandomApply([RandomExposureDown(factor_range=(0.42, 0.95))], p=0.18),
|
| 192 |
+
transforms.RandomApply([RandomColorTemperature(strength_range=(0.03, 0.12))], p=0.18),
|
| 193 |
+
transforms.RandomApply([JPEGCompression(quality_range=(65, 96))], p=0.72),
|
| 194 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.65, 0.99))], p=0.18),
|
| 195 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.08, 0.65))], p=0.10),
|
| 196 |
+
transforms.RandomApply([RandomMotionBlur(sizes=(3,))], p=0.05),
|
| 197 |
+
transforms.RandomApply([RandomUnsharpMask()], p=0.08),
|
| 198 |
+
transforms.ToTensor(),
|
| 199 |
+
transforms.RandomApply([RandomGamma(gamma_range=(0.78, 1.28))], p=0.22),
|
| 200 |
+
transforms.RandomApply([HighISONoise(std_range=(0.004, 0.035), chroma_range=(0.002, 0.018))], p=0.16),
|
| 201 |
+
transforms.RandomApply([RandomProjectorMoire(amplitude_range=(0.004, 0.016))], p=0.05),
|
| 202 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.002, 0.014))], p=0.12),
|
| 203 |
+
normalize,
|
| 204 |
+
])
|
| 205 |
+
|
| 206 |
+
if mode == "train" and profile == "web_real_guard":
|
| 207 |
+
return transforms.Compose([
|
| 208 |
+
transforms.RandomResizedCrop(
|
| 209 |
+
image_size,
|
| 210 |
+
scale=(0.76, 1.0),
|
| 211 |
+
ratio=(0.82, 1.20),
|
| 212 |
+
interpolation=interpolation,
|
| 213 |
+
antialias=True,
|
| 214 |
+
),
|
| 215 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 216 |
+
transforms.ColorJitter(brightness=0.12, contrast=0.12, saturation=0.08, hue=0.012),
|
| 217 |
+
transforms.RandomApply([RandomExposureDown(factor_range=(0.34, 0.94))], p=0.30),
|
| 218 |
+
transforms.RandomApply([RandomColorTemperature(strength_range=(0.03, 0.16))], p=0.24),
|
| 219 |
+
transforms.RandomApply([JPEGCompression(quality_range=(55, 96))], p=0.86),
|
| 220 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.56, 0.99))], p=0.26),
|
| 221 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.08, 0.90))], p=0.14),
|
| 222 |
+
transforms.RandomApply([RandomMotionBlur(sizes=(3,))], p=0.08),
|
| 223 |
+
transforms.RandomApply([RandomUnsharpMask()], p=0.12),
|
| 224 |
+
transforms.ToTensor(),
|
| 225 |
+
transforms.RandomApply([RandomGamma(gamma_range=(0.72, 1.34))], p=0.30),
|
| 226 |
+
transforms.RandomApply([HighISONoise(std_range=(0.006, 0.048), chroma_range=(0.003, 0.024))], p=0.24),
|
| 227 |
+
transforms.RandomApply([RandomProjectorMoire(amplitude_range=(0.004, 0.020))], p=0.07),
|
| 228 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.002, 0.018))], p=0.16),
|
| 229 |
+
normalize,
|
| 230 |
+
])
|
| 231 |
+
|
| 232 |
+
if mode == "train" and profile == "realworld_guard_v2":
|
| 233 |
+
return transforms.Compose([
|
| 234 |
+
transforms.RandomResizedCrop(
|
| 235 |
+
image_size,
|
| 236 |
+
scale=(0.80, 1.0),
|
| 237 |
+
ratio=(0.86, 1.16),
|
| 238 |
+
interpolation=interpolation,
|
| 239 |
+
antialias=True,
|
| 240 |
+
),
|
| 241 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 242 |
+
transforms.ColorJitter(brightness=0.09, contrast=0.10, saturation=0.06, hue=0.010),
|
| 243 |
+
transforms.RandomApply([RandomExposureDown(factor_range=(0.38, 0.96))], p=0.24),
|
| 244 |
+
transforms.RandomApply([RandomColorTemperature(strength_range=(0.025, 0.13))], p=0.20),
|
| 245 |
+
transforms.RandomApply([JPEGCompression(quality_range=(60, 96))], p=0.78),
|
| 246 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.62, 0.99))], p=0.22),
|
| 247 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.08, 0.76))], p=0.12),
|
| 248 |
+
transforms.RandomApply([RandomMotionBlur(sizes=(3,))], p=0.06),
|
| 249 |
+
transforms.RandomApply([RandomUnsharpMask()], p=0.10),
|
| 250 |
+
transforms.ToTensor(),
|
| 251 |
+
transforms.RandomApply([RandomGamma(gamma_range=(0.76, 1.30))], p=0.28),
|
| 252 |
+
transforms.RandomApply([HighISONoise(std_range=(0.005, 0.040), chroma_range=(0.002, 0.020))], p=0.20),
|
| 253 |
+
transforms.RandomApply([RandomProjectorMoire(amplitude_range=(0.003, 0.017))], p=0.06),
|
| 254 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.002, 0.015))], p=0.14),
|
| 255 |
+
normalize,
|
| 256 |
+
])
|
| 257 |
+
|
| 258 |
+
if mode == "train":
|
| 259 |
+
return transforms.Compose([
|
| 260 |
+
transforms.RandomResizedCrop(
|
| 261 |
+
image_size,
|
| 262 |
+
scale=(0.72, 1.0),
|
| 263 |
+
ratio=(0.80, 1.25),
|
| 264 |
+
interpolation=interpolation,
|
| 265 |
+
antialias=True,
|
| 266 |
+
),
|
| 267 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 268 |
+
transforms.ColorJitter(brightness=0.16, contrast=0.16, saturation=0.10, hue=0.018),
|
| 269 |
+
transforms.RandomApply([transforms.RandomGrayscale(p=1.0)], p=0.035),
|
| 270 |
+
transforms.RandomApply([RandomExposureDown()], p=0.30),
|
| 271 |
+
transforms.RandomApply([RandomColorTemperature()], p=0.20),
|
| 272 |
+
transforms.RandomApply([JPEGCompression(quality_range=(40, 95))], p=0.50),
|
| 273 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.45, 0.96))], p=0.35),
|
| 274 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.08, 1.10))], p=0.22),
|
| 275 |
+
transforms.RandomApply([RandomMotionBlur()], p=0.10),
|
| 276 |
+
transforms.RandomApply([RandomUnsharpMask()], p=0.14),
|
| 277 |
+
transforms.ToTensor(),
|
| 278 |
+
transforms.RandomApply([RandomShadowCrush()], p=0.12),
|
| 279 |
+
transforms.RandomApply([RandomGamma()], p=0.14),
|
| 280 |
+
transforms.RandomApply([HighISONoise()], p=0.26),
|
| 281 |
+
transforms.RandomApply([RandomProjectorMoire()], p=0.08),
|
| 282 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.002, 0.020))], p=0.20),
|
| 283 |
+
normalize,
|
| 284 |
+
])
|
| 285 |
+
|
| 286 |
+
if mode in ("val", "test"):
|
| 287 |
+
return transforms.Compose([
|
| 288 |
+
transforms.Resize(resize_size, interpolation=interpolation, antialias=True),
|
| 289 |
+
transforms.CenterCrop(image_size),
|
| 290 |
+
transforms.ToTensor(),
|
| 291 |
+
normalize,
|
| 292 |
+
])
|
| 293 |
+
|
| 294 |
+
raise ValueError(f"mode must be train, val, or test; got {mode}")
|
src/data/dataset.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
A-EYE 데이터셋 로더.
|
| 3 |
+
|
| 4 |
+
지원하는 폴더 구조 2가지:
|
| 5 |
+
|
| 6 |
+
[구조 A] flat - 직접 수집한 데이터, random split 사용
|
| 7 |
+
data/raw/
|
| 8 |
+
real/ ← 실제 이미지 (label=0)
|
| 9 |
+
fake/ ← AI 생성 이미지 (label=1)
|
| 10 |
+
|
| 11 |
+
[구조 B] pre-split - CIFAKE 등 이미 분할된 공개 데이터셋
|
| 12 |
+
data/raw/
|
| 13 |
+
train/
|
| 14 |
+
REAL/ (또는 real/)
|
| 15 |
+
FAKE/ (또는 fake/)
|
| 16 |
+
val/
|
| 17 |
+
REAL/
|
| 18 |
+
FAKE/
|
| 19 |
+
test/
|
| 20 |
+
REAL/
|
| 21 |
+
FAKE/
|
| 22 |
+
|
| 23 |
+
라벨 매핑: real / REAL → 0 | fake / FAKE → 1
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
import random
|
| 30 |
+
import sys
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Callable, List, Optional, Tuple
|
| 33 |
+
|
| 34 |
+
from PIL import Image, UnidentifiedImageError
|
| 35 |
+
import torch
|
| 36 |
+
from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
|
| 37 |
+
|
| 38 |
+
from .transforms import get_transforms
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ──────────────────────────────────────────────
|
| 42 |
+
# DataLoader 워커 자동 감지
|
| 43 |
+
# ──────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
def _auto_workers(requested: int) -> int:
|
| 46 |
+
"""
|
| 47 |
+
안전한 num_workers 값을 반환한다.
|
| 48 |
+
|
| 49 |
+
- requested >= 0: 그대로 반환 (사용자 지정값 존중)
|
| 50 |
+
- requested < 0: 플랫폼·CUDA 환경을 감지해 자동 결정
|
| 51 |
+
|
| 52 |
+
자동 결정 기준:
|
| 53 |
+
Windows + CUDA : 4 (멀티프로세싱 안정성과 속도 균형)
|
| 54 |
+
Windows + CPU : 0 (Windows DataLoader 멀티프로세싱 이슈)
|
| 55 |
+
Linux/Mac : min(8, cpu_count // 2)
|
| 56 |
+
"""
|
| 57 |
+
if requested >= 0:
|
| 58 |
+
return requested
|
| 59 |
+
|
| 60 |
+
is_windows = sys.platform == "win32"
|
| 61 |
+
has_cuda = torch.cuda.is_available()
|
| 62 |
+
|
| 63 |
+
if is_windows:
|
| 64 |
+
# Windows 는 spawn 방식 → CPU-only 는 오버헤드 > 이득
|
| 65 |
+
# CUDA 환경: 4 workers (안정성·속도 균형, 실측 NOT RESPONDING 없음)
|
| 66 |
+
return 4 if has_cuda else 0
|
| 67 |
+
else:
|
| 68 |
+
cpu_count = os.cpu_count() or 1
|
| 69 |
+
return min(8, max(1, cpu_count // 2))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ──────────────────────────────────────────────
|
| 73 |
+
# 상수
|
| 74 |
+
# ──────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
LABEL_MAP: dict[str, int] = {"real": 0, "fake": 1}
|
| 77 |
+
SUPPORTED_EXTS: set[str] = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"}
|
| 78 |
+
|
| 79 |
+
# 폴더명 → 라벨 (대소문자 무시)
|
| 80 |
+
# "ai" 는 AI 생성 이미지 (fake 와 동일하게 label=1)
|
| 81 |
+
_FOLDER_TO_LABEL: dict[str, int] = {
|
| 82 |
+
"real": 0, "REAL": 0,
|
| 83 |
+
"fake": 1, "FAKE": 1,
|
| 84 |
+
"ai": 1, "AI": 1,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ──────────────────────────────────────────────
|
| 89 |
+
# 내부 유틸: 샘플 수집
|
| 90 |
+
# ──────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
def _scan_class_dirs(root: Path) -> List[Tuple[Path, int]]:
|
| 93 |
+
"""
|
| 94 |
+
root 하위에서 real/fake (대소문자 무시) 폴더를 찾아
|
| 95 |
+
(이미지 경로, 라벨) 리스트를 반환한다.
|
| 96 |
+
"""
|
| 97 |
+
samples: List[Tuple[Path, int]] = []
|
| 98 |
+
for folder in sorted(root.iterdir()):
|
| 99 |
+
if not folder.is_dir():
|
| 100 |
+
continue
|
| 101 |
+
label = _FOLDER_TO_LABEL.get(folder.name)
|
| 102 |
+
if label is None:
|
| 103 |
+
continue
|
| 104 |
+
imgs = sorted(
|
| 105 |
+
p for p in folder.iterdir()
|
| 106 |
+
if p.suffix.lower() in SUPPORTED_EXTS
|
| 107 |
+
)
|
| 108 |
+
for p in imgs:
|
| 109 |
+
samples.append((p, label))
|
| 110 |
+
return samples
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _split_samples(
|
| 114 |
+
samples: List[Tuple[Path, int]],
|
| 115 |
+
train_ratio: float,
|
| 116 |
+
val_ratio: float,
|
| 117 |
+
seed: int,
|
| 118 |
+
) -> Tuple[List, List, List]:
|
| 119 |
+
"""
|
| 120 |
+
샘플 리스트를 train / val / test로 분할한다.
|
| 121 |
+
클래스별로 따로 섞은 뒤 합쳐서 비율을 맞춘다 (간단한 stratified).
|
| 122 |
+
"""
|
| 123 |
+
rng = random.Random(seed)
|
| 124 |
+
|
| 125 |
+
per_class: dict[int, list] = {}
|
| 126 |
+
for item in samples:
|
| 127 |
+
per_class.setdefault(item[1], []).append(item)
|
| 128 |
+
for v in per_class.values():
|
| 129 |
+
rng.shuffle(v)
|
| 130 |
+
|
| 131 |
+
train_all, val_all, test_all = [], [], []
|
| 132 |
+
for v in per_class.values():
|
| 133 |
+
n = len(v)
|
| 134 |
+
n_train = int(n * train_ratio)
|
| 135 |
+
n_val = int(n * val_ratio)
|
| 136 |
+
train_all.extend(v[:n_train])
|
| 137 |
+
val_all.extend(v[n_train:n_train + n_val])
|
| 138 |
+
test_all.extend(v[n_train + n_val:])
|
| 139 |
+
|
| 140 |
+
return train_all, val_all, test_all
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ──────────────────────────────────────────────
|
| 144 |
+
# 핵심 Dataset 클래스
|
| 145 |
+
# ──────────────────────────────────────────────
|
| 146 |
+
|
| 147 |
+
class AEyeDataset(Dataset):
|
| 148 |
+
"""
|
| 149 |
+
A-EYE 이진 분류 데이터셋.
|
| 150 |
+
|
| 151 |
+
samples 리스트를 직접 받아 동작한다. transform은 외부에서 주입한다.
|
| 152 |
+
build_dataloaders() 또는 from_folder() 팩토리 메서드를 통해 생성하는 것을 권장한다.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
samples: [(Path, label), ...] 형식의 (이미지 경로, 라벨) 리스트
|
| 156 |
+
transform: torchvision transform (None이면 PIL Image 그대로 반환)
|
| 157 |
+
label_map: 라벨 번호 → 클래스명 역매핑 (통계용)
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
def __init__(
|
| 161 |
+
self,
|
| 162 |
+
samples: List[Tuple[Path, int]],
|
| 163 |
+
transform: Optional[Callable] = None,
|
| 164 |
+
label_map: Optional[dict[int, str]] = None,
|
| 165 |
+
):
|
| 166 |
+
if len(samples) == 0:
|
| 167 |
+
raise RuntimeError("samples가 비어있습니다. 데이터 경로를 확인하세요.")
|
| 168 |
+
self.samples = samples
|
| 169 |
+
self.transform = transform
|
| 170 |
+
self._label_to_name: dict[int, str] = label_map or {v: k for k, v in LABEL_MAP.items()}
|
| 171 |
+
|
| 172 |
+
# ── 팩토리 메서드 ────────────────────────────
|
| 173 |
+
|
| 174 |
+
@classmethod
|
| 175 |
+
def from_folder(
|
| 176 |
+
cls,
|
| 177 |
+
root_dir: str | Path,
|
| 178 |
+
transform: Optional[Callable] = None,
|
| 179 |
+
) -> "AEyeDataset":
|
| 180 |
+
"""
|
| 181 |
+
단일 폴더(real/, fake/)에서 데이터셋을 생성한다. (구조 A)
|
| 182 |
+
"""
|
| 183 |
+
samples = _scan_class_dirs(Path(root_dir))
|
| 184 |
+
return cls(samples, transform)
|
| 185 |
+
|
| 186 |
+
# ── Dataset 인터페이스 ──────────────────────
|
| 187 |
+
|
| 188 |
+
def __len__(self) -> int:
|
| 189 |
+
return len(self.samples)
|
| 190 |
+
|
| 191 |
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, int]:
|
| 192 |
+
filepath, label = self.samples[idx]
|
| 193 |
+
try:
|
| 194 |
+
image = Image.open(filepath).convert("RGB")
|
| 195 |
+
except (UnidentifiedImageError, OSError) as e:
|
| 196 |
+
# TODO: 손상 이미지 발생 시 인근 샘플로 대체하는 전략 결정
|
| 197 |
+
raise RuntimeError(f"이미지 로드 실패: {filepath}") from e
|
| 198 |
+
|
| 199 |
+
if self.transform is not None:
|
| 200 |
+
image = self.transform(image)
|
| 201 |
+
|
| 202 |
+
return image, label
|
| 203 |
+
|
| 204 |
+
# ── 통계 ─────────────────────────────────────
|
| 205 |
+
|
| 206 |
+
def class_counts(self) -> dict[str, int]:
|
| 207 |
+
"""클래스별 샘플 수 반환 (클래스 불균형 확인용)."""
|
| 208 |
+
counts: dict[str, int] = {name: 0 for name in LABEL_MAP}
|
| 209 |
+
for _, label in self.samples:
|
| 210 |
+
counts[self._label_to_name[label]] += 1
|
| 211 |
+
return counts
|
| 212 |
+
|
| 213 |
+
def summary(self, tag: str = "") -> str:
|
| 214 |
+
counts = self.class_counts()
|
| 215 |
+
total = len(self)
|
| 216 |
+
prefix = f"[{tag}] " if tag else ""
|
| 217 |
+
lines = [
|
| 218 |
+
f"{prefix}총 {total}장",
|
| 219 |
+
f" real(0): {counts['real']}장",
|
| 220 |
+
f" fake(1): {counts['fake']}장",
|
| 221 |
+
]
|
| 222 |
+
imbalance = counts["real"] / max(counts["fake"], 1)
|
| 223 |
+
if not (0.5 <= imbalance <= 2.0):
|
| 224 |
+
lines.append(f" ** 클래스 불균형 주의 (real/fake = {imbalance:.2f})")
|
| 225 |
+
return "\n".join(lines)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ──────────────────────────────────────────────
|
| 229 |
+
# DataLoader 빌더
|
| 230 |
+
# ──────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
def build_dataloaders(
|
| 233 |
+
root_dir: str,
|
| 234 |
+
image_size: int = 224,
|
| 235 |
+
batch_size: int = 32,
|
| 236 |
+
num_workers: int = -1,
|
| 237 |
+
pin_memory: Optional[bool] = None,
|
| 238 |
+
persistent_workers: Optional[bool] = None,
|
| 239 |
+
prefetch_factor: Optional[int] = None,
|
| 240 |
+
train_ratio: float = 0.7,
|
| 241 |
+
val_ratio: float = 0.15,
|
| 242 |
+
seed: int = 42,
|
| 243 |
+
mean: Optional[list] = None,
|
| 244 |
+
std: Optional[list] = None,
|
| 245 |
+
balanced_sampler: bool = False,
|
| 246 |
+
) -> Tuple[DataLoader, DataLoader, DataLoader]:
|
| 247 |
+
"""
|
| 248 |
+
데이터 로더를 생성하고 반환한다.
|
| 249 |
+
|
| 250 |
+
폴더 구조를 자동 감지:
|
| 251 |
+
- 구조 B (pre-split): root_dir/train/, root_dir/val/, root_dir/test/ 가 있으면 사용
|
| 252 |
+
- 구조 A (flat): root_dir/real/, root_dir/fake/ 에서 직접 split
|
| 253 |
+
|
| 254 |
+
Args:
|
| 255 |
+
root_dir: 데이터 루트 경로
|
| 256 |
+
image_size: 리사이즈 크기
|
| 257 |
+
batch_size: 배치 크기
|
| 258 |
+
num_workers: 워커 수. -1이면 플랫폼·CUDA 환경에 따라 자동 결정
|
| 259 |
+
(Windows+CUDA=4, Windows+CPU=0, Linux=min(8, cpu//2))
|
| 260 |
+
pin_memory: None이면 CUDA 유무에 따라 자동 결정
|
| 261 |
+
persistent_workers: epoch 사이에 worker 프로세스를 유지할지 여부
|
| 262 |
+
prefetch_factor: worker당 미리 준비할 batch 수 (None → 기본값 2)
|
| 263 |
+
train_ratio: flat 구조일 때 train 비율
|
| 264 |
+
val_ratio: flat 구조일 때 val 비율
|
| 265 |
+
seed: 재현성 시드
|
| 266 |
+
mean / std: 정규화 파라미터
|
| 267 |
+
|
| 268 |
+
Returns:
|
| 269 |
+
(train_loader, val_loader, test_loader)
|
| 270 |
+
"""
|
| 271 |
+
# ── 리소스 자동 결정 ──���─────────────────────
|
| 272 |
+
num_workers = _auto_workers(num_workers)
|
| 273 |
+
if pin_memory is None:
|
| 274 |
+
pin_memory = torch.cuda.is_available()
|
| 275 |
+
root = Path(root_dir)
|
| 276 |
+
|
| 277 |
+
train_transform = get_transforms("train", image_size, mean, std)
|
| 278 |
+
eval_transform = get_transforms("val", image_size, mean, std)
|
| 279 |
+
|
| 280 |
+
# ── 구조 B: pre-split ─────────────────────
|
| 281 |
+
if (root / "train").exists() and (root / "test").exists():
|
| 282 |
+
train_samples = _scan_class_dirs(root / "train")
|
| 283 |
+
test_samples = _scan_class_dirs(root / "test")
|
| 284 |
+
|
| 285 |
+
# val 폴더가 없으면 test에서 절반 분리
|
| 286 |
+
if (root / "val").exists():
|
| 287 |
+
val_samples = _scan_class_dirs(root / "val")
|
| 288 |
+
else:
|
| 289 |
+
mid = len(test_samples) // 2
|
| 290 |
+
val_samples = test_samples[:mid]
|
| 291 |
+
test_samples = test_samples[mid:]
|
| 292 |
+
|
| 293 |
+
train_ds = AEyeDataset(train_samples, train_transform)
|
| 294 |
+
val_ds = AEyeDataset(val_samples, eval_transform)
|
| 295 |
+
test_ds = AEyeDataset(test_samples, eval_transform)
|
| 296 |
+
mode = "pre-split"
|
| 297 |
+
|
| 298 |
+
# ── 구조 A: flat + random split ───────────
|
| 299 |
+
else:
|
| 300 |
+
all_samples = _scan_class_dirs(root)
|
| 301 |
+
if len(all_samples) == 0:
|
| 302 |
+
raise RuntimeError(
|
| 303 |
+
f"이미지를 찾을 수 없습니다: {root}\n"
|
| 304 |
+
" 구조 A: root/real/, root/fake/\n"
|
| 305 |
+
" 구조 B: root/train/REAL/, root/test/FAKE/ 등"
|
| 306 |
+
)
|
| 307 |
+
train_samples, val_samples, test_samples = _split_samples(
|
| 308 |
+
all_samples, train_ratio, val_ratio, seed
|
| 309 |
+
)
|
| 310 |
+
train_ds = AEyeDataset(train_samples, train_transform)
|
| 311 |
+
val_ds = AEyeDataset(val_samples, eval_transform)
|
| 312 |
+
test_ds = AEyeDataset(test_samples, eval_transform)
|
| 313 |
+
mode = "flat+split"
|
| 314 |
+
|
| 315 |
+
# ── 출력 ──────────────────────────────────
|
| 316 |
+
_pw = persistent_workers if persistent_workers is not None else (True if num_workers > 0 else False)
|
| 317 |
+
_pf = prefetch_factor if prefetch_factor is not None else (2 if num_workers > 0 else None)
|
| 318 |
+
print(f"[DataLoader] 구조: {mode}")
|
| 319 |
+
print(f"[DataLoader] workers={num_workers} pin_memory={pin_memory} "
|
| 320 |
+
f"persistent_workers={_pw} prefetch_factor={_pf} batch={batch_size}")
|
| 321 |
+
print(train_ds.summary("train"))
|
| 322 |
+
print(val_ds.summary("val"))
|
| 323 |
+
print(test_ds.summary("test"))
|
| 324 |
+
|
| 325 |
+
def _make(ds: AEyeDataset, shuffle: bool, balanced: bool = False) -> DataLoader:
|
| 326 |
+
loader_kwargs: dict = dict(
|
| 327 |
+
batch_size=batch_size,
|
| 328 |
+
num_workers=num_workers,
|
| 329 |
+
pin_memory=pin_memory,
|
| 330 |
+
)
|
| 331 |
+
if balanced:
|
| 332 |
+
counts: dict[int, int] = {}
|
| 333 |
+
for _, label in ds.samples:
|
| 334 |
+
counts[label] = counts.get(label, 0) + 1
|
| 335 |
+
sample_weights = torch.DoubleTensor([
|
| 336 |
+
1.0 / counts[label] for _, label in ds.samples
|
| 337 |
+
])
|
| 338 |
+
loader_kwargs["sampler"] = WeightedRandomSampler(
|
| 339 |
+
sample_weights,
|
| 340 |
+
num_samples=len(sample_weights),
|
| 341 |
+
replacement=True,
|
| 342 |
+
)
|
| 343 |
+
print("[DataLoader] balanced_sampler=True")
|
| 344 |
+
else:
|
| 345 |
+
loader_kwargs["shuffle"] = shuffle
|
| 346 |
+
if num_workers > 0:
|
| 347 |
+
loader_kwargs["persistent_workers"] = (
|
| 348 |
+
persistent_workers if persistent_workers is not None else True
|
| 349 |
+
)
|
| 350 |
+
loader_kwargs["prefetch_factor"] = (
|
| 351 |
+
prefetch_factor if prefetch_factor is not None else 2
|
| 352 |
+
)
|
| 353 |
+
return DataLoader(ds, **loader_kwargs)
|
| 354 |
+
|
| 355 |
+
return (
|
| 356 |
+
_make(train_ds, shuffle=True, balanced=balanced_sampler),
|
| 357 |
+
_make(val_ds, shuffle=False),
|
| 358 |
+
_make(test_ds, shuffle=False),
|
| 359 |
+
)
|
src/data/transforms.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image preprocessing and augmentation for real/fake classification."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from PIL import Image
|
| 10 |
+
from torchvision import transforms
|
| 11 |
+
from torchvision.transforms import InterpolationMode
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
| 15 |
+
IMAGENET_STD = [0.229, 0.224, 0.225]
|
| 16 |
+
|
| 17 |
+
CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073]
|
| 18 |
+
CLIP_STD = [0.26862954, 0.26130258, 0.27577711]
|
| 19 |
+
|
| 20 |
+
_NORM_PRESETS: dict[str, tuple[list[float], list[float]]] = {
|
| 21 |
+
"imagenet": (IMAGENET_MEAN, IMAGENET_STD),
|
| 22 |
+
"clip": (CLIP_MEAN, CLIP_STD),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class JPEGCompression:
|
| 27 |
+
"""Random JPEG round-trip augmentation."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, quality_range: tuple[int, int] = (70, 100)):
|
| 30 |
+
self.quality_range = quality_range
|
| 31 |
+
|
| 32 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 33 |
+
quality = random.randint(*self.quality_range)
|
| 34 |
+
buf = io.BytesIO()
|
| 35 |
+
img.save(buf, format="JPEG", quality=quality)
|
| 36 |
+
buf.seek(0)
|
| 37 |
+
return Image.open(buf).copy()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DownsampleUpsample:
|
| 41 |
+
"""Resolution loss augmentation that preserves the original canvas size."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, scale_range: tuple[float, float] = (0.65, 0.95)):
|
| 44 |
+
self.scale_range = scale_range
|
| 45 |
+
|
| 46 |
+
def __call__(self, img: Image.Image) -> Image.Image:
|
| 47 |
+
w, h = img.size
|
| 48 |
+
scale = random.uniform(*self.scale_range)
|
| 49 |
+
small_w = max(1, int(w * scale))
|
| 50 |
+
small_h = max(1, int(h * scale))
|
| 51 |
+
small = img.resize((small_w, small_h), Image.Resampling.BILINEAR)
|
| 52 |
+
return small.resize((w, h), Image.Resampling.BILINEAR)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class GaussianNoise:
|
| 56 |
+
"""Small sensor-like noise on tensor images in [0, 1]."""
|
| 57 |
+
|
| 58 |
+
def __init__(self, std_range: tuple[float, float] = (0.003, 0.015)):
|
| 59 |
+
self.std_range = std_range
|
| 60 |
+
|
| 61 |
+
def __call__(self, tensor: torch.Tensor) -> torch.Tensor:
|
| 62 |
+
std = random.uniform(*self.std_range)
|
| 63 |
+
return torch.clamp(tensor + torch.randn_like(tensor) * std, 0.0, 1.0)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_transforms(
|
| 67 |
+
mode: str,
|
| 68 |
+
image_size: int = 224,
|
| 69 |
+
mean: list | None = None,
|
| 70 |
+
std: list | None = None,
|
| 71 |
+
norm_preset: str = "imagenet",
|
| 72 |
+
) -> transforms.Compose:
|
| 73 |
+
if mean is None or std is None:
|
| 74 |
+
preset_mean, preset_std = _NORM_PRESETS.get(norm_preset, (IMAGENET_MEAN, IMAGENET_STD))
|
| 75 |
+
mean = mean or preset_mean
|
| 76 |
+
std = std or preset_std
|
| 77 |
+
|
| 78 |
+
normalize = transforms.Normalize(mean=mean, std=std)
|
| 79 |
+
resize_size = int(round(image_size / 0.875))
|
| 80 |
+
interpolation = InterpolationMode.BICUBIC
|
| 81 |
+
|
| 82 |
+
if mode == "train":
|
| 83 |
+
return transforms.Compose([
|
| 84 |
+
transforms.RandomResizedCrop(
|
| 85 |
+
image_size,
|
| 86 |
+
scale=(0.80, 1.0),
|
| 87 |
+
ratio=(0.85, 1.18),
|
| 88 |
+
interpolation=interpolation,
|
| 89 |
+
antialias=True,
|
| 90 |
+
),
|
| 91 |
+
transforms.RandomHorizontalFlip(p=0.5),
|
| 92 |
+
transforms.ColorJitter(brightness=0.12, contrast=0.12, saturation=0.08, hue=0.02),
|
| 93 |
+
transforms.RandomApply([JPEGCompression(quality_range=(70, 100))], p=0.35),
|
| 94 |
+
transforms.RandomApply([DownsampleUpsample(scale_range=(0.65, 0.95))], p=0.15),
|
| 95 |
+
transforms.RandomApply([transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 0.8))], p=0.15),
|
| 96 |
+
transforms.ToTensor(),
|
| 97 |
+
transforms.RandomApply([GaussianNoise(std_range=(0.003, 0.015))], p=0.15),
|
| 98 |
+
normalize,
|
| 99 |
+
])
|
| 100 |
+
|
| 101 |
+
if mode in ("val", "test"):
|
| 102 |
+
return transforms.Compose([
|
| 103 |
+
transforms.Resize(resize_size, interpolation=interpolation, antialias=True),
|
| 104 |
+
transforms.CenterCrop(image_size),
|
| 105 |
+
transforms.ToTensor(),
|
| 106 |
+
normalize,
|
| 107 |
+
])
|
| 108 |
+
|
| 109 |
+
raise ValueError(f"mode must be one of 'train', 'val', or 'test', got '{mode}'")
|
src/inference/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# TODO: 추론 파이프라인 구현 완료 후 export 추가
|
src/models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .vit_classifier import ViTClassifier, AVAILABLE_BACKBONES
|
| 2 |
+
from .clip_classifier import CLIPClassifier, AVAILABLE_CLIP_BACKBONES, DEFAULT_PROMPTS
|
| 3 |
+
from .hybrid import HybridClassifier
|
| 4 |
+
from .artifact_hybrid import ArtifactAwareLoRAHybridClassifier
|
| 5 |
+
from .factory import build_model
|
src/models/artifact_gated_hybrid.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gated artifact-aware ViT + CLIP + LoRA classifier.
|
| 2 |
+
|
| 3 |
+
This variant keeps the exp26 backbone names intact for partial checkpoint
|
| 4 |
+
loading, then adds branch gates and auxiliary branch heads. The gate helps the
|
| 5 |
+
model reduce over-reliance on FFT/SRM when real low-light noise looks synthetic.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
|
| 13 |
+
from .artifact_hybrid import ArtifactAwareLoRAHybridClassifier, _build_deep_head
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ArtifactAwareGatedLoRAHybridClassifier(ArtifactAwareLoRAHybridClassifier):
|
| 17 |
+
"""Artifact hybrid with learnable branch reliability gates."""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
gate_dim: int = 384,
|
| 22 |
+
gate_dropout: float = 0.12,
|
| 23 |
+
gate_semantic_only: bool = False,
|
| 24 |
+
gate_init_bias_semantic: float | None = None,
|
| 25 |
+
**kwargs,
|
| 26 |
+
):
|
| 27 |
+
super().__init__(**kwargs)
|
| 28 |
+
self.gate_dim = gate_dim
|
| 29 |
+
self.gate_semantic_only = bool(gate_semantic_only)
|
| 30 |
+
self.gate_init_bias_semantic = gate_init_bias_semantic
|
| 31 |
+
branch_dims = {
|
| 32 |
+
"vit": self.vit_dim,
|
| 33 |
+
"clip": self.clip_dim,
|
| 34 |
+
"fft": self.fft_dim,
|
| 35 |
+
"srm": self.srm_dim,
|
| 36 |
+
}
|
| 37 |
+
gate_input_dim = (
|
| 38 |
+
branch_dims["vit"] + branch_dims["clip"]
|
| 39 |
+
if self.gate_semantic_only
|
| 40 |
+
else sum(branch_dims.values())
|
| 41 |
+
)
|
| 42 |
+
self.branch_order = ("vit", "clip", "fft", "srm")
|
| 43 |
+
self.branch_proj = nn.ModuleDict({
|
| 44 |
+
name: nn.Sequential(
|
| 45 |
+
nn.LayerNorm(dim),
|
| 46 |
+
nn.Linear(dim, gate_dim),
|
| 47 |
+
nn.GELU(),
|
| 48 |
+
nn.Dropout(p=gate_dropout),
|
| 49 |
+
)
|
| 50 |
+
for name, dim in branch_dims.items()
|
| 51 |
+
})
|
| 52 |
+
self.gate_net = nn.Sequential(
|
| 53 |
+
nn.LayerNorm(gate_input_dim),
|
| 54 |
+
nn.Linear(gate_input_dim, 256),
|
| 55 |
+
nn.GELU(),
|
| 56 |
+
nn.Dropout(p=gate_dropout),
|
| 57 |
+
nn.Linear(256, len(self.branch_order)),
|
| 58 |
+
)
|
| 59 |
+
self.aux_heads = nn.ModuleDict({
|
| 60 |
+
name: nn.Sequential(
|
| 61 |
+
nn.LayerNorm(dim),
|
| 62 |
+
nn.Dropout(p=gate_dropout),
|
| 63 |
+
nn.Linear(dim, self.num_classes),
|
| 64 |
+
)
|
| 65 |
+
for name, dim in branch_dims.items()
|
| 66 |
+
})
|
| 67 |
+
fused_dim = gate_dim * len(self.branch_order) + len(self.branch_order)
|
| 68 |
+
self.head = _build_deep_head(fused_dim, self.num_classes, kwargs.get("dropout", 0.3))
|
| 69 |
+
self.fused_dim = fused_dim
|
| 70 |
+
self._init_new_layers()
|
| 71 |
+
|
| 72 |
+
def _init_new_layers(self) -> None:
|
| 73 |
+
for module in (self.branch_proj, self.gate_net, self.aux_heads):
|
| 74 |
+
for layer in module.modules():
|
| 75 |
+
if isinstance(layer, nn.Linear):
|
| 76 |
+
nn.init.trunc_normal_(layer.weight, std=0.02)
|
| 77 |
+
if layer.bias is not None:
|
| 78 |
+
nn.init.zeros_(layer.bias)
|
| 79 |
+
if self.gate_init_bias_semantic is not None:
|
| 80 |
+
gate_final = self.gate_net[-1]
|
| 81 |
+
if isinstance(gate_final, nn.Linear) and gate_final.bias is not None:
|
| 82 |
+
bias = torch.empty_like(gate_final.bias)
|
| 83 |
+
value = float(self.gate_init_bias_semantic)
|
| 84 |
+
for idx, name in enumerate(self.branch_order):
|
| 85 |
+
bias[idx] = value if name in ("vit", "clip") else -value
|
| 86 |
+
with torch.no_grad():
|
| 87 |
+
gate_final.bias.copy_(bias)
|
| 88 |
+
|
| 89 |
+
def _branch_features(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
|
| 90 |
+
vit_feat = self.vit_encoder.forward_features(x)
|
| 91 |
+
clip_feat = self.clip_encoder.forward_features(x)
|
| 92 |
+
raw = self._to_raw_rgb(x)
|
| 93 |
+
fft_feat = self.fft_branch(raw)
|
| 94 |
+
srm_feat = self.srm_branch(x)
|
| 95 |
+
return {
|
| 96 |
+
"vit": vit_feat,
|
| 97 |
+
"clip": clip_feat,
|
| 98 |
+
"fft": fft_feat,
|
| 99 |
+
"srm": srm_feat,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
def _gated_features(
|
| 103 |
+
self,
|
| 104 |
+
branches: dict[str, torch.Tensor],
|
| 105 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 106 |
+
if self.gate_semantic_only:
|
| 107 |
+
gate_input = torch.cat([branches["vit"], branches["clip"]], dim=-1)
|
| 108 |
+
else:
|
| 109 |
+
gate_input = torch.cat([branches[name] for name in self.branch_order], dim=-1)
|
| 110 |
+
gates = torch.softmax(self.gate_net(gate_input), dim=-1)
|
| 111 |
+
projected = []
|
| 112 |
+
for idx, name in enumerate(self.branch_order):
|
| 113 |
+
projected.append(self.branch_proj[name](branches[name]) * gates[:, idx : idx + 1])
|
| 114 |
+
return torch.cat([*projected, gates], dim=-1), gates
|
| 115 |
+
|
| 116 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 117 |
+
branches = self._branch_features(x)
|
| 118 |
+
fused, _ = self._gated_features(branches)
|
| 119 |
+
return fused
|
| 120 |
+
|
| 121 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 122 |
+
return self.head(self.forward_features(x))
|
| 123 |
+
|
| 124 |
+
def forward_with_aux(self, x: torch.Tensor) -> dict[str, torch.Tensor | dict[str, torch.Tensor]]:
|
| 125 |
+
branches = self._branch_features(x)
|
| 126 |
+
fused, gates = self._gated_features(branches)
|
| 127 |
+
aux_logits = {name: self.aux_heads[name](branches[name]) for name in self.branch_order}
|
| 128 |
+
return {
|
| 129 |
+
"logits": self.head(fused),
|
| 130 |
+
"aux_logits": aux_logits,
|
| 131 |
+
"gates": gates,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
def param_summary(self) -> str:
|
| 135 |
+
total = sum(p.numel() for p in self.parameters())
|
| 136 |
+
trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 137 |
+
return (
|
| 138 |
+
f"[ArtifactGatedLoRAHybrid: {self.vit_backbone_name} + {self.clip_backbone_name} "
|
| 139 |
+
f"r={self.lora_r} alpha={self.lora_alpha} gate_dim={self.gate_dim} "
|
| 140 |
+
f"gate={'semantic' if self.gate_semantic_only else 'all'} "
|
| 141 |
+
f"gate_bias={self.gate_init_bias_semantic}] "
|
| 142 |
+
f"total={total:,} trainable={trainable:,} ({100 * trainable / total:.2f}%)"
|
| 143 |
+
)
|
src/models/artifact_hybrid.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Artifact-aware ViT + CLIP + LoRA classifier.
|
| 2 |
+
|
| 3 |
+
This model keeps the proven experiment-17 backbone, then adds lightweight
|
| 4 |
+
forensic branches for frequency and high-pass residual evidence.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
|
| 12 |
+
from .fft_branch import FFTBranch
|
| 13 |
+
from .lora_hybrid import LoRAHybridClassifier
|
| 14 |
+
from .srm_branch import CLIP_MEAN, CLIP_STD, SRMBranch
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _build_deep_head(in_dim: int, num_classes: int, dropout: float) -> nn.Sequential:
|
| 18 |
+
hidden = max(512, in_dim // 2)
|
| 19 |
+
bottleneck = max(256, hidden // 2)
|
| 20 |
+
head = nn.Sequential(
|
| 21 |
+
nn.LayerNorm(in_dim),
|
| 22 |
+
nn.Linear(in_dim, hidden),
|
| 23 |
+
nn.GELU(),
|
| 24 |
+
nn.Dropout(p=dropout),
|
| 25 |
+
nn.Linear(hidden, bottleneck),
|
| 26 |
+
nn.GELU(),
|
| 27 |
+
nn.Dropout(p=dropout * 0.75),
|
| 28 |
+
nn.Linear(bottleneck, num_classes),
|
| 29 |
+
)
|
| 30 |
+
for module in head:
|
| 31 |
+
if isinstance(module, nn.Linear):
|
| 32 |
+
nn.init.trunc_normal_(module.weight, std=0.02)
|
| 33 |
+
if module.bias is not None:
|
| 34 |
+
nn.init.zeros_(module.bias)
|
| 35 |
+
return head
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ArtifactAwareLoRAHybridClassifier(LoRAHybridClassifier):
|
| 39 |
+
"""ViT+CLIP LoRA fused with raw-FFT and SRM residual branches."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
fft_dim: int = 256,
|
| 44 |
+
fft_dropout: float = 0.1,
|
| 45 |
+
srm_dim: int = 128,
|
| 46 |
+
srm_dropout: float = 0.1,
|
| 47 |
+
lora_r: int = 32,
|
| 48 |
+
lora_alpha: int = 64,
|
| 49 |
+
lora_dropout: float = 0.1,
|
| 50 |
+
lora_vit: bool = True,
|
| 51 |
+
vit_backbone: str = "vit_b_16",
|
| 52 |
+
clip_backbone: str = "clip-vit-b-32",
|
| 53 |
+
fusion: str = "concat",
|
| 54 |
+
num_classes: int = 2,
|
| 55 |
+
freeze_vit: bool = True,
|
| 56 |
+
freeze_clip: bool = True,
|
| 57 |
+
vit_pretrained: bool = True,
|
| 58 |
+
dropout: float = 0.3,
|
| 59 |
+
):
|
| 60 |
+
super().__init__(
|
| 61 |
+
lora_r=lora_r,
|
| 62 |
+
lora_alpha=lora_alpha,
|
| 63 |
+
lora_dropout=lora_dropout,
|
| 64 |
+
lora_vit=lora_vit,
|
| 65 |
+
vit_backbone=vit_backbone,
|
| 66 |
+
clip_backbone=clip_backbone,
|
| 67 |
+
fusion=fusion,
|
| 68 |
+
num_classes=num_classes,
|
| 69 |
+
freeze_vit=freeze_vit,
|
| 70 |
+
freeze_clip=freeze_clip,
|
| 71 |
+
vit_pretrained=vit_pretrained,
|
| 72 |
+
dropout=dropout,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
self.fft_dim = fft_dim
|
| 76 |
+
self.srm_dim = srm_dim
|
| 77 |
+
self.register_buffer("clip_mean", torch.tensor(CLIP_MEAN).view(1, 3, 1, 1))
|
| 78 |
+
self.register_buffer("clip_std", torch.tensor(CLIP_STD).view(1, 3, 1, 1))
|
| 79 |
+
|
| 80 |
+
self.fft_branch = FFTBranch(out_dim=fft_dim, dropout=fft_dropout)
|
| 81 |
+
self.srm_branch = SRMBranch(out_dim=srm_dim, dropout=srm_dropout)
|
| 82 |
+
|
| 83 |
+
fused_dim = self.fused_dim + fft_dim + srm_dim
|
| 84 |
+
self.head = _build_deep_head(fused_dim, num_classes, dropout)
|
| 85 |
+
self.fused_dim = fused_dim
|
| 86 |
+
|
| 87 |
+
def _to_raw_rgb(self, x: torch.Tensor) -> torch.Tensor:
|
| 88 |
+
return (x * self.clip_std + self.clip_mean).clamp(0.0, 1.0)
|
| 89 |
+
|
| 90 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 91 |
+
vit_feat = self.vit_encoder.forward_features(x)
|
| 92 |
+
clip_feat = self.clip_encoder.forward_features(x)
|
| 93 |
+
raw = self._to_raw_rgb(x)
|
| 94 |
+
fft_feat = self.fft_branch(raw)
|
| 95 |
+
srm_feat = self.srm_branch(x)
|
| 96 |
+
return torch.cat([vit_feat, clip_feat, fft_feat, srm_feat], dim=-1)
|
| 97 |
+
|
| 98 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 99 |
+
return self.head(self.forward_features(x))
|
| 100 |
+
|
| 101 |
+
def freeze_lora(self) -> None:
|
| 102 |
+
for name, param in self.named_parameters():
|
| 103 |
+
if "lora_" in name:
|
| 104 |
+
param.requires_grad_(False)
|
| 105 |
+
for module in (self.fft_branch, self.srm_branch, self.head):
|
| 106 |
+
for param in module.parameters():
|
| 107 |
+
param.requires_grad_(True)
|
| 108 |
+
|
| 109 |
+
def unfreeze_lora(self) -> None:
|
| 110 |
+
for name, param in self.named_parameters():
|
| 111 |
+
if "lora_" in name:
|
| 112 |
+
param.requires_grad_(True)
|
| 113 |
+
|
| 114 |
+
def load_partial_checkpoint(self, checkpoint_path: str, device: str = "cpu") -> dict:
|
| 115 |
+
"""Load matching 17-style weights and skip new or shape-mismatched layers."""
|
| 116 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=True)
|
| 117 |
+
state = ckpt.get("model_state_dict", ckpt)
|
| 118 |
+
own_state = self.state_dict()
|
| 119 |
+
|
| 120 |
+
loaded, skipped = 0, 0
|
| 121 |
+
loaded_keys, skipped_keys = [], []
|
| 122 |
+
for key, value in state.items():
|
| 123 |
+
if key in own_state and own_state[key].shape == value.shape:
|
| 124 |
+
own_state[key] = value
|
| 125 |
+
loaded += 1
|
| 126 |
+
if len(loaded_keys) < 20:
|
| 127 |
+
loaded_keys.append(key)
|
| 128 |
+
else:
|
| 129 |
+
skipped += 1
|
| 130 |
+
if len(skipped_keys) < 20:
|
| 131 |
+
skipped_keys.append(key)
|
| 132 |
+
|
| 133 |
+
self.load_state_dict(own_state, strict=False)
|
| 134 |
+
return {
|
| 135 |
+
"loaded": loaded,
|
| 136 |
+
"skipped": skipped,
|
| 137 |
+
"loaded_keys_preview": loaded_keys,
|
| 138 |
+
"skipped_keys_preview": skipped_keys,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
def param_summary(self) -> str:
|
| 142 |
+
total = sum(p.numel() for p in self.parameters())
|
| 143 |
+
trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 144 |
+
srm_params = sum(p.numel() for p in self.srm_branch.parameters())
|
| 145 |
+
fft_params = sum(p.numel() for p in self.fft_branch.parameters())
|
| 146 |
+
return (
|
| 147 |
+
f"[ArtifactLoRAHybrid: {self.vit_backbone_name} + {self.clip_backbone_name} "
|
| 148 |
+
f"r={self.lora_r} alpha={self.lora_alpha}] "
|
| 149 |
+
f"total={total:,} trainable={trainable:,} ({100 * trainable / total:.2f}%) "
|
| 150 |
+
f"fft={fft_params:,} srm={srm_params:,}"
|
| 151 |
+
)
|
src/models/clip_classifier.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLIP 기반 real/fake 이진 분류기.
|
| 3 |
+
|
| 4 |
+
지원 모드:
|
| 5 |
+
zeroshot 텍스트 프롬프트와 코사인 유사도만으로 분류 (학습 불필요)
|
| 6 |
+
linear CLIP vision encoder 동결, 선형 헤드만 학습
|
| 7 |
+
finetune vision encoder + 선형 헤드 전체 fine-tuning
|
| 8 |
+
|
| 9 |
+
지원 백본:
|
| 10 |
+
clip-vit-b-32 openai/clip-vit-base-patch32 embed_dim=512
|
| 11 |
+
clip-vit-b-16 openai/clip-vit-base-patch16 embed_dim=512
|
| 12 |
+
clip-vit-l-14 openai/clip-vit-large-patch14 embed_dim=768
|
| 13 |
+
|
| 14 |
+
설계 원칙:
|
| 15 |
+
- forward_features(x) 가 (B, embed_dim) L2-정규화 벡터를 반환
|
| 16 |
+
→ Hybrid 모델에서 ViT 특징과 바로 결합 가능
|
| 17 |
+
- forward(x) 가 logits (B, num_classes) 를 반환
|
| 18 |
+
→ 기존 Trainer, CrossEntropyLoss 와 완전 호환
|
| 19 |
+
- image_only=True 시 text encoder/tokenizer 초기화 생략
|
| 20 |
+
→ HybridClassifier 에서 불필요한 비용 제거
|
| 21 |
+
|
| 22 |
+
오프라인 사용:
|
| 23 |
+
캐시 우선 로딩: 로컬 캐시가 있으면 네트워크 없이 즉시 로드.
|
| 24 |
+
환경 변수 HF_HUB_OFFLINE=1 또는 TRANSFORMERS_OFFLINE=1 로
|
| 25 |
+
오프라인 전용 모드 강제 가능.
|
| 26 |
+
|
| 27 |
+
텍스트 임베딩은 __init__ 시점에 1회 계산 후 buffer로 등록됨.
|
| 28 |
+
학습 중에는 이미지만 인코딩하므로 overhead 없음.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import logging
|
| 34 |
+
import os
|
| 35 |
+
import warnings
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
from typing import Dict, List, Optional
|
| 38 |
+
|
| 39 |
+
import torch
|
| 40 |
+
import torch.nn as nn
|
| 41 |
+
import torch.nn.functional as F
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
# transformers 없이도 import 는 허용
|
| 46 |
+
try:
|
| 47 |
+
from transformers import (
|
| 48 |
+
CLIPVisionModelWithProjection,
|
| 49 |
+
CLIPVisionConfig,
|
| 50 |
+
CLIPTextModelWithProjection,
|
| 51 |
+
CLIPTokenizer,
|
| 52 |
+
)
|
| 53 |
+
_TRANSFORMERS_AVAILABLE = True
|
| 54 |
+
except ImportError:
|
| 55 |
+
_TRANSFORMERS_AVAILABLE = False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ──────────────────────────────────────────────
|
| 59 |
+
# 오프라인 친화적 로드 헬퍼
|
| 60 |
+
# ──────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
def _is_offline_mode() -> bool:
|
| 63 |
+
"""HF_HUB_OFFLINE 또는 TRANSFORMERS_OFFLINE 환경변수가 설정됐는지 확인."""
|
| 64 |
+
return (
|
| 65 |
+
os.environ.get("HF_HUB_OFFLINE", "0") == "1"
|
| 66 |
+
or os.environ.get("TRANSFORMERS_OFFLINE", "0") == "1"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _load_pretrained(cls, hf_name: str, **kwargs):
|
| 71 |
+
"""
|
| 72 |
+
캐시 우선 로딩 전략 — 불필요한 네트워크 요청 없이 동작.
|
| 73 |
+
|
| 74 |
+
전략:
|
| 75 |
+
1차: 로컬 캐시 시도 (local_files_only=True) → 캐시 있으면 즉시 반환
|
| 76 |
+
2차: 캐시 없고 오프라인 모드면 → 명확한 에러
|
| 77 |
+
3차: 캐시 없고 온라인이면 → 다운로드 시도
|
| 78 |
+
|
| 79 |
+
오프라인 강제:
|
| 80 |
+
HF_HUB_OFFLINE=1 또는 TRANSFORMERS_OFFLINE=1 설정 시
|
| 81 |
+
2차(다운로드)를 건너뛰고 즉시 에러 발생.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
cls: transformers 모델/토크나이저 클래스
|
| 85 |
+
hf_name: HuggingFace 모델 ID
|
| 86 |
+
kwargs: from_pretrained 에 전달할 추가 인자
|
| 87 |
+
"""
|
| 88 |
+
# 1차: 로컬 캐시 우선 시도
|
| 89 |
+
try:
|
| 90 |
+
return cls.from_pretrained(hf_name, local_files_only=True, **kwargs)
|
| 91 |
+
except Exception:
|
| 92 |
+
pass # 캐시 없음 → 2/3차로 진행
|
| 93 |
+
|
| 94 |
+
# 오프라인 모드이면 다운로드 없이 실패
|
| 95 |
+
if _is_offline_mode():
|
| 96 |
+
raise RuntimeError(
|
| 97 |
+
f"오프라인 모드에서 '{hf_name}' 캐시를 찾을 수 없습니다.\n"
|
| 98 |
+
f" 해결: 네트워크 연결 후 한 번 실행해서 캐시를 만드세요:\n"
|
| 99 |
+
f" python -c \"from transformers import {cls.__name__}; "
|
| 100 |
+
f"{cls.__name__}.from_pretrained('{hf_name}')\""
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# 2차: 다운로드 시도
|
| 104 |
+
try:
|
| 105 |
+
return cls.from_pretrained(hf_name, **kwargs)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise RuntimeError(
|
| 108 |
+
f"'{hf_name}' 로드 실패 (캐시 없음 + 다운로드 실패).\n"
|
| 109 |
+
f" 원인: {e}\n"
|
| 110 |
+
f" 해결: 네트워크 연결 상태를 확인하거나,\n"
|
| 111 |
+
f" TRANSFORMERS_OFFLINE=1 설정 후 캐시 경로를 확인하세요."
|
| 112 |
+
) from e
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _build_random_clip_vision(hf_name: str) -> nn.Module:
|
| 116 |
+
"""Build the CLIP vision architecture without downloading base weights."""
|
| 117 |
+
if hf_name == "openai/clip-vit-base-patch32":
|
| 118 |
+
config = CLIPVisionConfig(
|
| 119 |
+
hidden_size=768,
|
| 120 |
+
intermediate_size=3072,
|
| 121 |
+
projection_dim=512,
|
| 122 |
+
num_hidden_layers=12,
|
| 123 |
+
num_attention_heads=12,
|
| 124 |
+
image_size=224,
|
| 125 |
+
patch_size=32,
|
| 126 |
+
)
|
| 127 |
+
elif hf_name == "openai/clip-vit-base-patch16":
|
| 128 |
+
config = CLIPVisionConfig(
|
| 129 |
+
hidden_size=768,
|
| 130 |
+
intermediate_size=3072,
|
| 131 |
+
projection_dim=512,
|
| 132 |
+
num_hidden_layers=12,
|
| 133 |
+
num_attention_heads=12,
|
| 134 |
+
image_size=224,
|
| 135 |
+
patch_size=16,
|
| 136 |
+
)
|
| 137 |
+
elif hf_name == "openai/clip-vit-large-patch14":
|
| 138 |
+
config = CLIPVisionConfig(
|
| 139 |
+
hidden_size=1024,
|
| 140 |
+
intermediate_size=4096,
|
| 141 |
+
projection_dim=768,
|
| 142 |
+
num_hidden_layers=24,
|
| 143 |
+
num_attention_heads=16,
|
| 144 |
+
image_size=224,
|
| 145 |
+
patch_size=14,
|
| 146 |
+
)
|
| 147 |
+
else:
|
| 148 |
+
raise ValueError(f"Unsupported CLIP backbone for random init: {hf_name}")
|
| 149 |
+
return CLIPVisionModelWithProjection(config)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ──────────────────────────────────────────────
|
| 153 |
+
# 백본 레지스트리
|
| 154 |
+
# ──────────────────────────────────────────────
|
| 155 |
+
|
| 156 |
+
@dataclass
|
| 157 |
+
class _CLIPSpec:
|
| 158 |
+
hf_name: str # HuggingFace 모델 ID
|
| 159 |
+
embed_dim: int # projection_dim (최종 임베딩 차원)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
_CLIP_REGISTRY: Dict[str, _CLIPSpec] = {
|
| 163 |
+
"clip-vit-b-32": _CLIPSpec("openai/clip-vit-base-patch32", 512),
|
| 164 |
+
"clip-vit-b-16": _CLIPSpec("openai/clip-vit-base-patch16", 512),
|
| 165 |
+
"clip-vit-l-14": _CLIPSpec("openai/clip-vit-large-patch14", 768),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
AVAILABLE_CLIP_BACKBONES = list(_CLIP_REGISTRY.keys())
|
| 169 |
+
|
| 170 |
+
# ──────────────────────────────────────────────
|
| 171 |
+
# 기본 텍스트 프롬프트
|
| 172 |
+
# ──────────────────────────────────────────────
|
| 173 |
+
# label 0 = real, label 1 = fake
|
| 174 |
+
# 클래스당 여러 프롬프트를 평균해 더 안정적인 임베딩을 얻는다.
|
| 175 |
+
|
| 176 |
+
DEFAULT_PROMPTS: Dict[int, List[str]] = {
|
| 177 |
+
0: [
|
| 178 |
+
"a real photograph",
|
| 179 |
+
"a genuine photo taken by a camera",
|
| 180 |
+
"a real image captured by a human",
|
| 181 |
+
],
|
| 182 |
+
1: [
|
| 183 |
+
"an AI-generated image",
|
| 184 |
+
"a fake image created by artificial intelligence",
|
| 185 |
+
"a synthetic image generated by a neural network",
|
| 186 |
+
],
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ──────────────────────────────────────────────
|
| 191 |
+
# 분류기
|
| 192 |
+
# ──────────────────────────────────────────────
|
| 193 |
+
|
| 194 |
+
class CLIPClassifier(nn.Module):
|
| 195 |
+
"""
|
| 196 |
+
CLIP 기반 real/fake 이진 분류기.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
backbone_name: 백본 키 (clip-vit-b-32 | clip-vit-b-16 | clip-vit-l-14)
|
| 200 |
+
mode: 분류 방식 (zeroshot | linear | finetune)
|
| 201 |
+
num_classes: 출력 클래스 수 (기본 2)
|
| 202 |
+
prompts: {label_int: [str, ...]} 프롬프트 딕셔너리
|
| 203 |
+
None이면 DEFAULT_PROMPTS 사용
|
| 204 |
+
dropout: linear / finetune 헤드 dropout 비율
|
| 205 |
+
temperature: zeroshot 코사인 유사도 스케일 (CLIP logit_scale 사용 권장)
|
| 206 |
+
image_only: True 면 text encoder / tokenizer / text_embeddings 초기화 생략.
|
| 207 |
+
HybridClassifier 등 image feature 만 필요한 경우에 사용.
|
| 208 |
+
zeroshot 모드와 함께 쓸 수 없음.
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
VALID_MODES = ("zeroshot", "linear", "finetune")
|
| 212 |
+
|
| 213 |
+
def __init__(
|
| 214 |
+
self,
|
| 215 |
+
backbone_name: str = "clip-vit-b-32",
|
| 216 |
+
mode: str = "linear",
|
| 217 |
+
num_classes: int = 2,
|
| 218 |
+
prompts: Optional[Dict[int, List[str]]] = None,
|
| 219 |
+
dropout: float = 0.1,
|
| 220 |
+
temperature: float = 1.0,
|
| 221 |
+
image_only: bool = False,
|
| 222 |
+
):
|
| 223 |
+
super().__init__()
|
| 224 |
+
|
| 225 |
+
if not _TRANSFORMERS_AVAILABLE:
|
| 226 |
+
raise ImportError(
|
| 227 |
+
"CLIP 모델은 transformers 라이브러리가 필요합니다.\n"
|
| 228 |
+
" pip install transformers"
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
if backbone_name not in _CLIP_REGISTRY:
|
| 232 |
+
raise ValueError(
|
| 233 |
+
f"지원하지 않는 CLIP 백본: '{backbone_name}'\n"
|
| 234 |
+
f"사용 가능: {AVAILABLE_CLIP_BACKBONES}"
|
| 235 |
+
)
|
| 236 |
+
if mode not in self.VALID_MODES:
|
| 237 |
+
raise ValueError(
|
| 238 |
+
f"지원하지 않는 mode: '{mode}'\n"
|
| 239 |
+
f"사용 가능: {self.VALID_MODES}"
|
| 240 |
+
)
|
| 241 |
+
if image_only and mode == "zeroshot":
|
| 242 |
+
raise ValueError(
|
| 243 |
+
"image_only=True 와 mode='zeroshot' 은 함께 쓸 수 없습니다.\n"
|
| 244 |
+
"zeroshot 은 text encoder 가 필요합니다."
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
self.backbone_name = backbone_name
|
| 248 |
+
self.mode = mode
|
| 249 |
+
self.num_classes = num_classes
|
| 250 |
+
self.temperature = temperature
|
| 251 |
+
self.image_only = image_only
|
| 252 |
+
|
| 253 |
+
spec = _CLIP_REGISTRY[backbone_name]
|
| 254 |
+
self.embed_dim = spec.embed_dim
|
| 255 |
+
hf_name = spec.hf_name
|
| 256 |
+
|
| 257 |
+
# ── Vision encoder ───────────────────────
|
| 258 |
+
if os.environ.get("A_EYE_RANDOM_INIT_BACKBONES", "").strip().lower() in {"1", "true", "yes"}:
|
| 259 |
+
self.vision_model = _build_random_clip_vision(hf_name)
|
| 260 |
+
else:
|
| 261 |
+
with warnings.catch_warnings():
|
| 262 |
+
warnings.simplefilter("ignore")
|
| 263 |
+
self.vision_model = _load_pretrained(CLIPVisionModelWithProjection, hf_name)
|
| 264 |
+
|
| 265 |
+
if mode in ("zeroshot", "linear"):
|
| 266 |
+
# vision encoder 파라미터 동결
|
| 267 |
+
for param in self.vision_model.parameters():
|
| 268 |
+
param.requires_grad = False
|
| 269 |
+
|
| 270 |
+
# ── Text encoder + 프롬프트 임베딩 사전계산 ──
|
| 271 |
+
# image_only=True 면 text branch 전체를 건너뜀 (Hybrid 전용 경로)
|
| 272 |
+
if not image_only:
|
| 273 |
+
self._prompts: Dict[int, List[str]] = prompts or DEFAULT_PROMPTS
|
| 274 |
+
self._register_text_embeddings(hf_name, num_classes)
|
| 275 |
+
else:
|
| 276 |
+
self._prompts = {}
|
| 277 |
+
|
| 278 |
+
# ── 분류 헤드 (linear / finetune 에만 사용) ──
|
| 279 |
+
if mode in ("linear", "finetune"):
|
| 280 |
+
self.head = nn.Sequential(
|
| 281 |
+
nn.LayerNorm(self.embed_dim),
|
| 282 |
+
nn.Dropout(p=dropout),
|
| 283 |
+
nn.Linear(self.embed_dim, num_classes),
|
| 284 |
+
)
|
| 285 |
+
nn.init.trunc_normal_(self.head[-1].weight, std=0.02)
|
| 286 |
+
nn.init.zeros_(self.head[-1].bias)
|
| 287 |
+
else:
|
| 288 |
+
self.head = None # zeroshot은 head 없음
|
| 289 |
+
|
| 290 |
+
# ──────────────────────────────────────────
|
| 291 |
+
# 텍스트 임베딩 사전계산
|
| 292 |
+
# ──────────────────────────────────────────
|
| 293 |
+
|
| 294 |
+
@torch.no_grad()
|
| 295 |
+
def _register_text_embeddings(self, hf_name: str, num_classes: int):
|
| 296 |
+
"""
|
| 297 |
+
각 클래스의 프롬프트를 인코딩하고 buffer로 등록.
|
| 298 |
+
device 이동 시 자동으로 따라온다.
|
| 299 |
+
프롬프트가 여럿이면 평균을 L2 정규화해 사용한다.
|
| 300 |
+
"""
|
| 301 |
+
with warnings.catch_warnings():
|
| 302 |
+
warnings.simplefilter("ignore")
|
| 303 |
+
text_model = _load_pretrained(CLIPTextModelWithProjection, hf_name)
|
| 304 |
+
tokenizer = _load_pretrained(CLIPTokenizer, hf_name)
|
| 305 |
+
text_model.eval()
|
| 306 |
+
|
| 307 |
+
class_embeds = []
|
| 308 |
+
for label in range(num_classes):
|
| 309 |
+
label_prompts = self._prompts.get(label, [f"class {label}"])
|
| 310 |
+
tokens = tokenizer(
|
| 311 |
+
label_prompts,
|
| 312 |
+
return_tensors="pt",
|
| 313 |
+
padding=True,
|
| 314 |
+
truncation=True,
|
| 315 |
+
max_length=77,
|
| 316 |
+
)
|
| 317 |
+
text_out = text_model(**tokens)
|
| 318 |
+
# (num_prompts, embed_dim) → 평균 → (embed_dim,)
|
| 319 |
+
embeds = text_out.text_embeds # (P, D)
|
| 320 |
+
embeds = F.normalize(embeds, dim=-1) # 정규화
|
| 321 |
+
embed = embeds.mean(dim=0) # 프롬프트 앙상블
|
| 322 |
+
embed = F.normalize(embed, dim=-1) # 재정규화
|
| 323 |
+
class_embeds.append(embed)
|
| 324 |
+
|
| 325 |
+
# (num_classes, embed_dim)
|
| 326 |
+
text_matrix = torch.stack(class_embeds, dim=0)
|
| 327 |
+
# buffer: 학습 파라미터는 아니지만 state_dict에 저장되고 device 이동도 자동
|
| 328 |
+
self.register_buffer("text_embeddings", text_matrix)
|
| 329 |
+
|
| 330 |
+
del text_model, tokenizer
|
| 331 |
+
|
| 332 |
+
# ──────────────────────────────────────────
|
| 333 |
+
# Forward
|
| 334 |
+
# ──────────────────────────────────────────
|
| 335 |
+
|
| 336 |
+
def encode_image(self, x: torch.Tensor) -> torch.Tensor:
|
| 337 |
+
"""
|
| 338 |
+
이미지 배치 → L2 정규화된 이미지 임베딩 (B, embed_dim).
|
| 339 |
+
|
| 340 |
+
Hybrid 모델에서 이 메서드를 직접 호출해 특징벡터를 사용한다.
|
| 341 |
+
"""
|
| 342 |
+
vision_out = self.vision_model(pixel_values=x)
|
| 343 |
+
embeds = vision_out.image_embeds # (B, embed_dim)
|
| 344 |
+
return F.normalize(embeds, dim=-1)
|
| 345 |
+
|
| 346 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 347 |
+
"""encode_image 의 별칭. ViTClassifier 와 인터페이스 통일."""
|
| 348 |
+
return self.encode_image(x)
|
| 349 |
+
|
| 350 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 351 |
+
"""
|
| 352 |
+
Returns:
|
| 353 |
+
logits (B, num_classes)
|
| 354 |
+
zeroshot: 코사인 유사도 기반 logit
|
| 355 |
+
linear/finetune: 선형 헤드 출력
|
| 356 |
+
"""
|
| 357 |
+
img_embeds = self.encode_image(x) # (B, D), L2-normalized
|
| 358 |
+
|
| 359 |
+
if self.mode == "zeroshot":
|
| 360 |
+
# image_only=True 로 생성한 경우 text_embeddings 없음
|
| 361 |
+
if self.image_only or not hasattr(self, "text_embeddings"):
|
| 362 |
+
raise RuntimeError(
|
| 363 |
+
"image_only=True 로 초기화된 CLIPClassifier 는 "
|
| 364 |
+
"zeroshot forward 를 지원하지 않습니다."
|
| 365 |
+
)
|
| 366 |
+
logits = (img_embeds @ self.text_embeddings.T) / self.temperature
|
| 367 |
+
return logits
|
| 368 |
+
|
| 369 |
+
# linear / finetune
|
| 370 |
+
return self.head(img_embeds)
|
| 371 |
+
|
| 372 |
+
@torch.no_grad()
|
| 373 |
+
def predict_proba(self, x: torch.Tensor) -> torch.Tensor:
|
| 374 |
+
"""Softmax 확률 (B, num_classes). 추론 전용."""
|
| 375 |
+
self.eval()
|
| 376 |
+
return torch.softmax(self(x), dim=-1)
|
| 377 |
+
|
| 378 |
+
# ──────────────────────────────────────────
|
| 379 |
+
# 유틸
|
| 380 |
+
# ──────────────────────────────────────────
|
| 381 |
+
|
| 382 |
+
def trainable_params(self) -> int:
|
| 383 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 384 |
+
|
| 385 |
+
def total_params(self) -> int:
|
| 386 |
+
return sum(p.numel() for p in self.parameters())
|
| 387 |
+
|
| 388 |
+
def param_summary(self) -> str:
|
| 389 |
+
trainable = self.trainable_params()
|
| 390 |
+
total = self.total_params()
|
| 391 |
+
return (
|
| 392 |
+
f"[{self.backbone_name} / {self.mode}] "
|
| 393 |
+
f"전체 {total:,} | 학습 가능 {trainable:,} "
|
| 394 |
+
f"({100 * trainable / total:.1f}%)"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
def get_prompt_summary(self) -> str:
|
| 398 |
+
"""등록된 프롬프트를 출력한다."""
|
| 399 |
+
lines = ["[등록된 프롬프트]"]
|
| 400 |
+
for label, prm_list in self._prompts.items():
|
| 401 |
+
for p in prm_list:
|
| 402 |
+
lines.append(f" label={label}: '{p}'")
|
| 403 |
+
return "\n".join(lines)
|
src/models/factory.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config 기반 모델 팩토리.
|
| 3 |
+
|
| 4 |
+
사용:
|
| 5 |
+
with open("configs/config.yaml") as f:
|
| 6 |
+
cfg = yaml.safe_load(f)
|
| 7 |
+
model = build_model(cfg)
|
| 8 |
+
|
| 9 |
+
config.yaml 예시:
|
| 10 |
+
model:
|
| 11 |
+
type: "vit"
|
| 12 |
+
backbone: "vit_b_16"
|
| 13 |
+
num_classes: 2
|
| 14 |
+
pretrained: true
|
| 15 |
+
freeze_backbone: false
|
| 16 |
+
dropout: 0.1
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
|
| 23 |
+
from .vit_classifier import ViTClassifier, AVAILABLE_BACKBONES
|
| 24 |
+
from .clip_classifier import CLIPClassifier, AVAILABLE_CLIP_BACKBONES
|
| 25 |
+
from .hybrid import HybridClassifier
|
| 26 |
+
from .lora_hybrid import LoRAHybridClassifier
|
| 27 |
+
from .lora_hybrid_fft import LoRAHybridFFTClassifier
|
| 28 |
+
from .artifact_hybrid import ArtifactAwareLoRAHybridClassifier
|
| 29 |
+
from .artifact_gated_hybrid import ArtifactAwareGatedLoRAHybridClassifier
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def build_model(cfg: dict) -> nn.Module:
|
| 33 |
+
"""
|
| 34 |
+
config 딕셔너리로부터 모델을 생성한다.
|
| 35 |
+
|
| 36 |
+
지원:
|
| 37 |
+
type = "vit" → ViTClassifier
|
| 38 |
+
type = "clip" → CLIPClassifier
|
| 39 |
+
type = "hybrid" → HybridClassifier (ViT + CLIP concat)
|
| 40 |
+
type = "hybrid_lora" → LoRAHybridClassifier (CLIP에 LoRA 적용)
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
cfg: config.yaml 전체를 파싱한 딕셔너리
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
nn.Module
|
| 47 |
+
"""
|
| 48 |
+
model_cfg = cfg.get("model", {})
|
| 49 |
+
model_type = model_cfg.get("type", "vit").lower()
|
| 50 |
+
|
| 51 |
+
if model_type == "vit":
|
| 52 |
+
backbone = model_cfg.get("backbone", "vit_b_16")
|
| 53 |
+
if backbone not in AVAILABLE_BACKBONES:
|
| 54 |
+
raise ValueError(
|
| 55 |
+
f"model.backbone '{backbone}' 은 지원하지 않습니다.\n"
|
| 56 |
+
f"사용 가능: {AVAILABLE_BACKBONES}"
|
| 57 |
+
)
|
| 58 |
+
return ViTClassifier(
|
| 59 |
+
backbone_name=backbone,
|
| 60 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 61 |
+
pretrained=model_cfg.get("pretrained", True),
|
| 62 |
+
freeze_backbone=model_cfg.get("freeze_backbone", False),
|
| 63 |
+
dropout=model_cfg.get("dropout", 0.1),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
elif model_type == "clip":
|
| 67 |
+
backbone = model_cfg.get("backbone", "clip-vit-b-32")
|
| 68 |
+
if backbone not in AVAILABLE_CLIP_BACKBONES:
|
| 69 |
+
raise ValueError(
|
| 70 |
+
f"model.backbone '{backbone}' 은 지원하지 않습니다.\n"
|
| 71 |
+
f"사용 가능: {AVAILABLE_CLIP_BACKBONES}"
|
| 72 |
+
)
|
| 73 |
+
return CLIPClassifier(
|
| 74 |
+
backbone_name=backbone,
|
| 75 |
+
mode=model_cfg.get("clip_mode", "linear"),
|
| 76 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 77 |
+
prompts=model_cfg.get("prompts", None),
|
| 78 |
+
dropout=model_cfg.get("dropout", 0.1),
|
| 79 |
+
temperature=model_cfg.get("temperature", 1.0),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
elif model_type == "hybrid":
|
| 83 |
+
return HybridClassifier(
|
| 84 |
+
vit_backbone=model_cfg.get("backbone", "vit_b_16"),
|
| 85 |
+
clip_backbone=model_cfg.get("clip_backbone", "clip-vit-b-32"),
|
| 86 |
+
fusion=model_cfg.get("fusion", "concat"),
|
| 87 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 88 |
+
freeze_vit=model_cfg.get("freeze_vit", True),
|
| 89 |
+
freeze_clip=model_cfg.get("freeze_clip", True),
|
| 90 |
+
vit_pretrained=model_cfg.get("pretrained", True),
|
| 91 |
+
dropout=model_cfg.get("dropout", 0.1),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
elif model_type == "hybrid_lora":
|
| 95 |
+
lora_cfg = model_cfg.get("lora", {})
|
| 96 |
+
return LoRAHybridClassifier(
|
| 97 |
+
lora_r=lora_cfg.get("r", 16),
|
| 98 |
+
lora_alpha=lora_cfg.get("alpha", 32),
|
| 99 |
+
lora_dropout=lora_cfg.get("dropout", 0.05),
|
| 100 |
+
lora_vit=lora_cfg.get("vit", True),
|
| 101 |
+
vit_backbone=model_cfg.get("backbone", "vit_b_16"),
|
| 102 |
+
clip_backbone=model_cfg.get("clip_backbone", "clip-vit-b-32"),
|
| 103 |
+
fusion=model_cfg.get("fusion", "concat"),
|
| 104 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 105 |
+
freeze_vit=model_cfg.get("freeze_vit", True),
|
| 106 |
+
freeze_clip=model_cfg.get("freeze_clip", True),
|
| 107 |
+
vit_pretrained=model_cfg.get("pretrained", True),
|
| 108 |
+
dropout=model_cfg.get("dropout", 0.1),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
elif model_type == "hybrid_lora_fft":
|
| 112 |
+
lora_cfg = model_cfg.get("lora", {})
|
| 113 |
+
fft_cfg = model_cfg.get("fft", {})
|
| 114 |
+
return LoRAHybridFFTClassifier(
|
| 115 |
+
fft_dim=fft_cfg.get("dim", 256),
|
| 116 |
+
fft_dropout=fft_cfg.get("dropout", 0.1),
|
| 117 |
+
lora_r=lora_cfg.get("r", 32),
|
| 118 |
+
lora_alpha=lora_cfg.get("alpha", 64),
|
| 119 |
+
lora_dropout=lora_cfg.get("dropout", 0.1),
|
| 120 |
+
lora_vit=lora_cfg.get("vit", True),
|
| 121 |
+
vit_backbone=model_cfg.get("backbone", "vit_b_16"),
|
| 122 |
+
clip_backbone=model_cfg.get("clip_backbone", "clip-vit-b-32"),
|
| 123 |
+
fusion=model_cfg.get("fusion", "concat"),
|
| 124 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 125 |
+
freeze_vit=model_cfg.get("freeze_vit", True),
|
| 126 |
+
freeze_clip=model_cfg.get("freeze_clip", True),
|
| 127 |
+
vit_pretrained=model_cfg.get("pretrained", True),
|
| 128 |
+
dropout=model_cfg.get("dropout", 0.3),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
elif model_type == "artifact_lora_hybrid":
|
| 132 |
+
lora_cfg = model_cfg.get("lora", {})
|
| 133 |
+
fft_cfg = model_cfg.get("fft", {})
|
| 134 |
+
srm_cfg = model_cfg.get("srm", {})
|
| 135 |
+
return ArtifactAwareLoRAHybridClassifier(
|
| 136 |
+
fft_dim=fft_cfg.get("dim", 256),
|
| 137 |
+
fft_dropout=fft_cfg.get("dropout", 0.1),
|
| 138 |
+
srm_dim=srm_cfg.get("dim", 128),
|
| 139 |
+
srm_dropout=srm_cfg.get("dropout", 0.1),
|
| 140 |
+
lora_r=lora_cfg.get("r", 32),
|
| 141 |
+
lora_alpha=lora_cfg.get("alpha", 64),
|
| 142 |
+
lora_dropout=lora_cfg.get("dropout", 0.1),
|
| 143 |
+
lora_vit=lora_cfg.get("vit", True),
|
| 144 |
+
vit_backbone=model_cfg.get("backbone", "vit_b_16"),
|
| 145 |
+
clip_backbone=model_cfg.get("clip_backbone", "clip-vit-b-32"),
|
| 146 |
+
fusion=model_cfg.get("fusion", "concat"),
|
| 147 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 148 |
+
freeze_vit=model_cfg.get("freeze_vit", True),
|
| 149 |
+
freeze_clip=model_cfg.get("freeze_clip", True),
|
| 150 |
+
vit_pretrained=model_cfg.get("pretrained", True),
|
| 151 |
+
dropout=model_cfg.get("dropout", 0.3),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
elif model_type == "artifact_lora_hybrid_gated":
|
| 155 |
+
lora_cfg = model_cfg.get("lora", {})
|
| 156 |
+
fft_cfg = model_cfg.get("fft", {})
|
| 157 |
+
srm_cfg = model_cfg.get("srm", {})
|
| 158 |
+
gate_cfg = model_cfg.get("gate", {})
|
| 159 |
+
return ArtifactAwareGatedLoRAHybridClassifier(
|
| 160 |
+
fft_dim=fft_cfg.get("dim", 256),
|
| 161 |
+
fft_dropout=fft_cfg.get("dropout", 0.1),
|
| 162 |
+
srm_dim=srm_cfg.get("dim", 128),
|
| 163 |
+
srm_dropout=srm_cfg.get("dropout", 0.1),
|
| 164 |
+
gate_dim=gate_cfg.get("dim", 384),
|
| 165 |
+
gate_dropout=gate_cfg.get("dropout", 0.12),
|
| 166 |
+
gate_semantic_only=gate_cfg.get("semantic_only", False),
|
| 167 |
+
gate_init_bias_semantic=gate_cfg.get("init_bias_semantic"),
|
| 168 |
+
lora_r=lora_cfg.get("r", 32),
|
| 169 |
+
lora_alpha=lora_cfg.get("alpha", 64),
|
| 170 |
+
lora_dropout=lora_cfg.get("dropout", 0.1),
|
| 171 |
+
lora_vit=lora_cfg.get("vit", True),
|
| 172 |
+
vit_backbone=model_cfg.get("backbone", "vit_b_16"),
|
| 173 |
+
clip_backbone=model_cfg.get("clip_backbone", "clip-vit-b-32"),
|
| 174 |
+
fusion=model_cfg.get("fusion", "concat"),
|
| 175 |
+
num_classes=model_cfg.get("num_classes", 2),
|
| 176 |
+
freeze_vit=model_cfg.get("freeze_vit", True),
|
| 177 |
+
freeze_clip=model_cfg.get("freeze_clip", True),
|
| 178 |
+
vit_pretrained=model_cfg.get("pretrained", True),
|
| 179 |
+
dropout=model_cfg.get("dropout", 0.3),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
else:
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"지원하지 않는 model.type: '{model_type}'\n"
|
| 185 |
+
f"사용 가능: 'vit', 'clip', 'hybrid', 'hybrid_lora', "
|
| 186 |
+
f"'hybrid_lora_fft', 'artifact_lora_hybrid', 'artifact_lora_hybrid_gated'"
|
| 187 |
+
)
|
src/models/fft_branch.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FFT(주파수 도메인) 특징 추출기.
|
| 3 |
+
|
| 4 |
+
AI 생성 이미지는 픽셀 도메인과 달리 주파수 도메인에서 독특한 패턴을 남긴다:
|
| 5 |
+
- 업샘플링(bilinear/nearest) → 특정 주파수 대역에 격자 아티팩트
|
| 6 |
+
- Attention 패턴 → 주기적인 고주파 패턴
|
| 7 |
+
- GAN/Diffusion 디코더 → 고주파 성분 분포 이상
|
| 8 |
+
|
| 9 |
+
FFTBranch 설계:
|
| 10 |
+
1. 입력 이미지를 채널별 2D FFT → log-magnitude 스펙트럼
|
| 11 |
+
2. 중심 이동(fftshift) → DC 성분이 중앙에 위치
|
| 12 |
+
3. 소형 CNN(3 conv) → Global Average Pooling → 256-dim 벡터
|
| 13 |
+
|
| 14 |
+
학습 파라미터 수: ~300K (기존 모델 대비 매우 작음)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FFTBranch(nn.Module):
|
| 25 |
+
"""
|
| 26 |
+
이미지 → 주파수 도메인 특징 벡터 (B, out_dim).
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
out_dim: 출력 특징 차원 (기본 256)
|
| 30 |
+
image_size: 입력 이미지 크기 (기본 224)
|
| 31 |
+
dropout: 드롭아웃 비율
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
out_dim: int = 256,
|
| 37 |
+
image_size: int = 224,
|
| 38 |
+
dropout: float = 0.1,
|
| 39 |
+
):
|
| 40 |
+
super().__init__()
|
| 41 |
+
self.out_dim = out_dim
|
| 42 |
+
self.image_size = image_size
|
| 43 |
+
|
| 44 |
+
# ── 소형 CNN (주파수 맵 처리) ──────────────────────────
|
| 45 |
+
# 입력: (B, 3, H, W) log-magnitude 스펙트럼
|
| 46 |
+
# 수용 영역이 넓게 (7x7 → 5x5 → 3x3)
|
| 47 |
+
self.conv = nn.Sequential(
|
| 48 |
+
# Block 1: 3 → 32
|
| 49 |
+
nn.Conv2d(3, 32, kernel_size=7, stride=2, padding=3, bias=False),
|
| 50 |
+
nn.BatchNorm2d(32),
|
| 51 |
+
nn.GELU(),
|
| 52 |
+
|
| 53 |
+
# Block 2: 32 → 64
|
| 54 |
+
nn.Conv2d(32, 64, kernel_size=5, stride=2, padding=2, bias=False),
|
| 55 |
+
nn.BatchNorm2d(64),
|
| 56 |
+
nn.GELU(),
|
| 57 |
+
|
| 58 |
+
# Block 3: 64 → 128
|
| 59 |
+
nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1, bias=False),
|
| 60 |
+
nn.BatchNorm2d(128),
|
| 61 |
+
nn.GELU(),
|
| 62 |
+
|
| 63 |
+
# Block 4: 128 → 128
|
| 64 |
+
nn.Conv2d(128, 128, kernel_size=3, stride=2, padding=1, bias=False),
|
| 65 |
+
nn.BatchNorm2d(128),
|
| 66 |
+
nn.GELU(),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Global Average Pooling → Flatten
|
| 70 |
+
self.gap = nn.AdaptiveAvgPool2d(1)
|
| 71 |
+
|
| 72 |
+
# 투영 레이어: 128 → out_dim
|
| 73 |
+
self.proj = nn.Sequential(
|
| 74 |
+
nn.Dropout(p=dropout),
|
| 75 |
+
nn.Linear(128, out_dim),
|
| 76 |
+
nn.LayerNorm(out_dim),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
self._init_weights()
|
| 80 |
+
|
| 81 |
+
def _init_weights(self):
|
| 82 |
+
for m in self.modules():
|
| 83 |
+
if isinstance(m, nn.Conv2d):
|
| 84 |
+
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
|
| 85 |
+
elif isinstance(m, nn.BatchNorm2d):
|
| 86 |
+
nn.init.ones_(m.weight)
|
| 87 |
+
nn.init.zeros_(m.bias)
|
| 88 |
+
elif isinstance(m, nn.Linear):
|
| 89 |
+
nn.init.trunc_normal_(m.weight, std=0.02)
|
| 90 |
+
if m.bias is not None:
|
| 91 |
+
nn.init.zeros_(m.bias)
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
def compute_fft_spectrum(x: torch.Tensor) -> torch.Tensor:
|
| 95 |
+
"""
|
| 96 |
+
이미지 → 채널별 log-magnitude FFT 스펙트럼.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
x: (B, C, H, W) float tensor, 정규화된 이미지
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
spectrum: (B, C, H, W) float tensor, 정규화된 log-magnitude
|
| 103 |
+
"""
|
| 104 |
+
# 2D FFT 적용 (채널별)
|
| 105 |
+
# x: (B, C, H, W) → complex
|
| 106 |
+
f = torch.fft.fft2(x, norm="ortho")
|
| 107 |
+
|
| 108 |
+
# DC 성분을 중앙으로 이동
|
| 109 |
+
f = torch.fft.fftshift(f, dim=(-2, -1))
|
| 110 |
+
|
| 111 |
+
# Log magnitude (수치 안정성: +1e-8)
|
| 112 |
+
magnitude = torch.abs(f)
|
| 113 |
+
log_magnitude = torch.log1p(magnitude)
|
| 114 |
+
|
| 115 |
+
# [0, 1]로 정규화 (배치 내 각 이미지별)
|
| 116 |
+
B, C, H, W = log_magnitude.shape
|
| 117 |
+
flat = log_magnitude.view(B, -1)
|
| 118 |
+
min_val = flat.min(dim=1, keepdim=True)[0].view(B, 1, 1, 1)
|
| 119 |
+
max_val = flat.max(dim=1, keepdim=True)[0].view(B, 1, 1, 1)
|
| 120 |
+
spectrum = (log_magnitude - min_val) / (max_val - min_val + 1e-8)
|
| 121 |
+
|
| 122 |
+
return spectrum
|
| 123 |
+
|
| 124 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 125 |
+
"""
|
| 126 |
+
Args:
|
| 127 |
+
x: (B, 3, H, W) 정규화된 이미지
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
feat: (B, out_dim) 주파수 도메인 특징 벡터
|
| 131 |
+
"""
|
| 132 |
+
# 1. FFT 스펙트럼 계산
|
| 133 |
+
spectrum = self.compute_fft_spectrum(x) # (B, 3, H, W)
|
| 134 |
+
|
| 135 |
+
# 2. CNN 통과
|
| 136 |
+
feat = self.conv(spectrum) # (B, 128, H', W')
|
| 137 |
+
|
| 138 |
+
# 3. Global Average Pooling
|
| 139 |
+
feat = self.gap(feat) # (B, 128, 1, 1)
|
| 140 |
+
feat = feat.flatten(1) # (B, 128)
|
| 141 |
+
|
| 142 |
+
# 4. 투영
|
| 143 |
+
feat = self.proj(feat) # (B, out_dim)
|
| 144 |
+
|
| 145 |
+
return feat
|
src/models/hybrid.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViT + CLIP Hybrid 분류기.
|
| 3 |
+
|
| 4 |
+
기본 fusion: concat → MLP classifier
|
| 5 |
+
ViT forward_features → (B, vit_dim) [예: 768]
|
| 6 |
+
CLIP forward_features → (B, clip_dim) [예: 512]
|
| 7 |
+
concat → (B, vit_dim + clip_dim) [예: 1280]
|
| 8 |
+
MLP head → (B, num_classes)
|
| 9 |
+
|
| 10 |
+
설계 원칙:
|
| 11 |
+
- 두 인코더의 forward_features() 를 재사용 (기존 코드 변경 없음)
|
| 12 |
+
- fusion 방식을 문자열로 선택 가능 → 추후 확장 대비
|
| 13 |
+
- freeze_vit / freeze_clip 으로 인코더별 동결 제어
|
| 14 |
+
- 헤드의 LayerNorm 이 ViT(비정규화) / CLIP(L2 정규화) 스케일 차이를 흡수
|
| 15 |
+
|
| 16 |
+
추후 fusion 확장 위치:
|
| 17 |
+
forward_features() 내의 _fuse() 메서드에 새 방식 추가
|
| 18 |
+
현재: "concat"
|
| 19 |
+
TODO: "weighted" (학습 가능한 가중치 합)
|
| 20 |
+
TODO: "attention" (cross-attention 기반 융합)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import warnings
|
| 26 |
+
from typing import Literal
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
|
| 31 |
+
from .vit_classifier import ViTClassifier, AVAILABLE_BACKBONES
|
| 32 |
+
from .clip_classifier import CLIPClassifier, AVAILABLE_CLIP_BACKBONES
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# 지원하는 fusion 방식
|
| 36 |
+
FUSION_TYPES = Literal["concat"] # 추후: "weighted", "attention"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _build_mlp_head(in_dim: int, num_classes: int, dropout: float) -> nn.Sequential:
|
| 40 |
+
"""
|
| 41 |
+
2-layer MLP 분류 헤드.
|
| 42 |
+
|
| 43 |
+
구조: LayerNorm → Linear(in → in//2) → GELU → Dropout → Linear(in//2 → C)
|
| 44 |
+
|
| 45 |
+
LayerNorm 을 첫 번째에 두어 ViT / CLIP 의 스케일 차이(~27 vs ~1)를 정규화한다.
|
| 46 |
+
"""
|
| 47 |
+
hidden = in_dim // 2
|
| 48 |
+
head = nn.Sequential(
|
| 49 |
+
nn.LayerNorm(in_dim),
|
| 50 |
+
nn.Linear(in_dim, hidden),
|
| 51 |
+
nn.GELU(),
|
| 52 |
+
nn.Dropout(p=dropout),
|
| 53 |
+
nn.Linear(hidden, num_classes),
|
| 54 |
+
)
|
| 55 |
+
# 마지막 레이어 초기화
|
| 56 |
+
nn.init.trunc_normal_(head[-1].weight, std=0.02)
|
| 57 |
+
nn.init.zeros_(head[-1].bias)
|
| 58 |
+
return head
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class HybridClassifier(nn.Module):
|
| 62 |
+
"""
|
| 63 |
+
ViT + CLIP Hybrid 이진 분류기.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
vit_backbone: ViT 백본 이름 (vit_b_16 | vit_b_32 | vit_l_16)
|
| 67 |
+
clip_backbone: CLIP 백본 이름 (clip-vit-b-32 | clip-vit-b-16 | clip-vit-l-14)
|
| 68 |
+
fusion: 특징 결합 방식 ("concat")
|
| 69 |
+
num_classes: 출력 클래스 수 (기본 2)
|
| 70 |
+
freeze_vit: True면 ViT backbone 동결 (헤드는 항상 학습)
|
| 71 |
+
freeze_clip: True면 CLIP vision encoder 동결
|
| 72 |
+
vit_pretrained: ViT ImageNet 사전학습 가중치 사용
|
| 73 |
+
dropout: MLP 헤드 드롭아웃 비율
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
vit_backbone: str = "vit_b_16",
|
| 79 |
+
clip_backbone: str = "clip-vit-b-32",
|
| 80 |
+
fusion: str = "concat",
|
| 81 |
+
num_classes: int = 2,
|
| 82 |
+
freeze_vit: bool = True,
|
| 83 |
+
freeze_clip: bool = True,
|
| 84 |
+
vit_pretrained: bool = True,
|
| 85 |
+
dropout: float = 0.1,
|
| 86 |
+
):
|
| 87 |
+
super().__init__()
|
| 88 |
+
|
| 89 |
+
# ── 입력 검증 ─────────────────────────────
|
| 90 |
+
if vit_backbone not in AVAILABLE_BACKBONES:
|
| 91 |
+
raise ValueError(
|
| 92 |
+
f"지원하지 않는 ViT 백본: '{vit_backbone}'\n"
|
| 93 |
+
f"사용 가능: {AVAILABLE_BACKBONES}"
|
| 94 |
+
)
|
| 95 |
+
if clip_backbone not in AVAILABLE_CLIP_BACKBONES:
|
| 96 |
+
raise ValueError(
|
| 97 |
+
f"지원하지 않는 CLIP 백본: '{clip_backbone}'\n"
|
| 98 |
+
f"사용 가능: {AVAILABLE_CLIP_BACKBONES}"
|
| 99 |
+
)
|
| 100 |
+
if fusion != "concat":
|
| 101 |
+
raise ValueError(
|
| 102 |
+
f"지원하지 않는 fusion 방식: '{fusion}'\n"
|
| 103 |
+
f"현재 지원: 'concat' (TODO: 'weighted', 'attention')"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
self.vit_backbone_name = vit_backbone
|
| 107 |
+
self.clip_backbone_name = clip_backbone
|
| 108 |
+
self.fusion = fusion
|
| 109 |
+
self.num_classes = num_classes
|
| 110 |
+
|
| 111 |
+
# ── ViT 인코더 ────────────────────────────
|
| 112 |
+
# freeze_backbone=False 로 생성 후 HybridClassifier가 직접 제어
|
| 113 |
+
self.vit_encoder = ViTClassifier(
|
| 114 |
+
backbone_name=vit_backbone,
|
| 115 |
+
num_classes=num_classes,
|
| 116 |
+
pretrained=vit_pretrained,
|
| 117 |
+
freeze_backbone=False,
|
| 118 |
+
dropout=dropout,
|
| 119 |
+
)
|
| 120 |
+
self.vit_dim = self.vit_encoder.hidden_dim # 예: 768
|
| 121 |
+
|
| 122 |
+
# ViT backbone: freeze_vit 값에 따라 명시적으로 설정
|
| 123 |
+
for param in self.vit_encoder.backbone.parameters():
|
| 124 |
+
param.requires_grad = not freeze_vit
|
| 125 |
+
# ViT 자체 헤드는 Hybrid에서 사용 안 함 → 항상 동결
|
| 126 |
+
for param in self.vit_encoder.head.parameters():
|
| 127 |
+
param.requires_grad = False
|
| 128 |
+
|
| 129 |
+
# ── CLIP 인코더 ───────────────────────────
|
| 130 |
+
# image_only=True: Hybrid 는 CLIP image feature 만 사용하므로
|
| 131 |
+
# text encoder / tokenizer / text_embeddings 초기화를 생략한다.
|
| 132 |
+
# mode="linear" 로 생성. CLIPClassifier 내부에서 vision_model을 동결하지만,
|
| 133 |
+
# HybridClassifier 가 freeze_clip 값에 따라 명시적으로 재설정한다.
|
| 134 |
+
self.clip_encoder = CLIPClassifier(
|
| 135 |
+
backbone_name=clip_backbone,
|
| 136 |
+
mode="linear",
|
| 137 |
+
num_classes=num_classes,
|
| 138 |
+
dropout=dropout,
|
| 139 |
+
image_only=True,
|
| 140 |
+
)
|
| 141 |
+
self.clip_dim = self.clip_encoder.embed_dim # 예: 512
|
| 142 |
+
|
| 143 |
+
# CLIP vision encoder: freeze_clip 값에 따라 명시적으로 설정
|
| 144 |
+
# (CLIPClassifier.__init__에서 mode="linear"가 freeze한 것을 재정의)
|
| 145 |
+
for param in self.clip_encoder.vision_model.parameters():
|
| 146 |
+
param.requires_grad = not freeze_clip
|
| 147 |
+
# CLIP 자체 헤드도 Hybrid에서 사용 안 함 → 항상 동결
|
| 148 |
+
if self.clip_encoder.head is not None:
|
| 149 |
+
for param in self.clip_encoder.head.parameters():
|
| 150 |
+
param.requires_grad = False
|
| 151 |
+
|
| 152 |
+
# ── Fusion 헤드 ───────────────────────────
|
| 153 |
+
self.fused_dim = self.vit_dim + self.clip_dim # 예: 1280
|
| 154 |
+
self.head = _build_mlp_head(self.fused_dim, num_classes, dropout)
|
| 155 |
+
|
| 156 |
+
# ──────────────────────────────────────────
|
| 157 |
+
# Fusion 로직
|
| 158 |
+
# ──────────────────────────────────────────
|
| 159 |
+
|
| 160 |
+
def _fuse(
|
| 161 |
+
self,
|
| 162 |
+
vit_feat: torch.Tensor, # (B, vit_dim)
|
| 163 |
+
clip_feat: torch.Tensor, # (B, clip_dim)
|
| 164 |
+
) -> torch.Tensor:
|
| 165 |
+
"""
|
| 166 |
+
두 특징벡터를 fusion 방식에 따라 결합한다.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
fused (B, fused_dim)
|
| 170 |
+
|
| 171 |
+
추후 새 fusion 방식 추가 위치:
|
| 172 |
+
elif self.fusion == "weighted":
|
| 173 |
+
...
|
| 174 |
+
elif self.fusion == "attention":
|
| 175 |
+
...
|
| 176 |
+
"""
|
| 177 |
+
if self.fusion == "concat":
|
| 178 |
+
return torch.cat([vit_feat, clip_feat], dim=-1) # (B, 1280)
|
| 179 |
+
|
| 180 |
+
# 이 라인에 도달하면 버그 → 생성자에서 검증했으므로 발생하지 않음
|
| 181 |
+
raise RuntimeError(f"알 수 없는 fusion: {self.fusion}")
|
| 182 |
+
|
| 183 |
+
# ──────────────────────────────────────────
|
| 184 |
+
# Forward
|
| 185 |
+
# ──────────────────────────────────────────
|
| 186 |
+
|
| 187 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 188 |
+
"""
|
| 189 |
+
이미지 → 결합된 특징벡터 (B, fused_dim).
|
| 190 |
+
|
| 191 |
+
XAI, 임베딩 시각화, Hybrid-of-Hybrid 확장 등에 사용.
|
| 192 |
+
"""
|
| 193 |
+
vit_feat = self.vit_encoder.forward_features(x) # (B, 768)
|
| 194 |
+
clip_feat = self.clip_encoder.forward_features(x) # (B, 512)
|
| 195 |
+
return self._fuse(vit_feat, clip_feat) # (B, 1280)
|
| 196 |
+
|
| 197 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 198 |
+
"""logits (B, num_classes) 반환."""
|
| 199 |
+
return self.head(self.forward_features(x))
|
| 200 |
+
|
| 201 |
+
@torch.no_grad()
|
| 202 |
+
def predict_proba(self, x: torch.Tensor) -> torch.Tensor:
|
| 203 |
+
"""Softmax 확률 (B, num_classes). 추론 전용."""
|
| 204 |
+
self.eval()
|
| 205 |
+
return torch.softmax(self(x), dim=-1)
|
| 206 |
+
|
| 207 |
+
# ──────────────────────────────────────────
|
| 208 |
+
# 유틸
|
| 209 |
+
# ──────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
def trainable_params(self) -> int:
|
| 212 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 213 |
+
|
| 214 |
+
def total_params(self) -> int:
|
| 215 |
+
return sum(p.numel() for p in self.parameters())
|
| 216 |
+
|
| 217 |
+
def param_summary(self) -> str:
|
| 218 |
+
trainable = self.trainable_params()
|
| 219 |
+
total = self.total_params()
|
| 220 |
+
return (
|
| 221 |
+
f"[Hybrid: {self.vit_backbone_name} + {self.clip_backbone_name} / {self.fusion}] "
|
| 222 |
+
f"전체 {total:,} | 학습 가능 {trainable:,} "
|
| 223 |
+
f"({100 * trainable / total:.1f}%)"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
def encoder_summary(self) -> str:
|
| 227 |
+
"""각 인코더의 학습 가능 파라미터 수를 출력한다."""
|
| 228 |
+
vit_total = sum(p.numel() for p in self.vit_encoder.parameters())
|
| 229 |
+
vit_train = sum(p.numel() for p in self.vit_encoder.parameters() if p.requires_grad)
|
| 230 |
+
clip_total = sum(p.numel() for p in self.clip_encoder.parameters())
|
| 231 |
+
clip_train = sum(p.numel() for p in self.clip_encoder.parameters() if p.requires_grad)
|
| 232 |
+
head_params = sum(p.numel() for p in self.head.parameters())
|
| 233 |
+
return (
|
| 234 |
+
f" ViT ({self.vit_backbone_name}): {vit_total:>12,} 전체 | {vit_train:>12,} 학습 가능\n"
|
| 235 |
+
f" CLIP ({self.clip_backbone_name}): {clip_total:>12,} 전체 | {clip_train:>12,} 학습 가능\n"
|
| 236 |
+
f" Fusion head: {head_params:>12,} (항상 학습)\n"
|
| 237 |
+
f" fused_dim: {self.vit_dim} + {self.clip_dim} = {self.fused_dim}"
|
| 238 |
+
)
|
src/models/lora_hybrid.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LoRA 적용 Hybrid 분류기.
|
| 3 |
+
|
| 4 |
+
HybridClassifier를 기반으로 CLIP vision encoder와 ViT encoder에
|
| 5 |
+
LoRA 어댑터를 주입한다.
|
| 6 |
+
|
| 7 |
+
설계 원칙:
|
| 8 |
+
- PEFT 없이 직접 LoRALinear 구현 (peft/transformers 버전 충돌 회피)
|
| 9 |
+
- CLIP: q_proj / v_proj 에 LoRA 적용
|
| 10 |
+
- ViT: out_proj (attention) + MLP 레이어에 LoRA 적용
|
| 11 |
+
- Trainer / factory 와 완전 호환 (forward, param_summary 동일 인터페이스)
|
| 12 |
+
|
| 13 |
+
LoRA 수식:
|
| 14 |
+
W' = W + (lora_alpha / r) * BA
|
| 15 |
+
A: (r, in_dim) B: (out_dim, r) → 추가 학습 파라미터 수 ≈ 2*r*d
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import math
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
|
| 25 |
+
from .hybrid import HybridClassifier
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class LoRALinear(nn.Module):
|
| 29 |
+
"""
|
| 30 |
+
기존 nn.Linear를 감싸서 LoRA 어댑터를 추가하는 레이어.
|
| 31 |
+
|
| 32 |
+
forward: y = x @ W^T + b + (alpha/r) * x @ A^T @ B^T
|
| 33 |
+
학습 파라미터: lora_A (r × in), lora_B (out × r) 만 requires_grad=True
|
| 34 |
+
기존 weight/bias: requires_grad=False (동결 유지)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, linear: nn.Linear, r: int, alpha: int, dropout: float = 0.0):
|
| 38 |
+
super().__init__()
|
| 39 |
+
self.base = linear
|
| 40 |
+
self.base.weight.requires_grad_(False)
|
| 41 |
+
if self.base.bias is not None:
|
| 42 |
+
self.base.bias.requires_grad_(False)
|
| 43 |
+
|
| 44 |
+
in_dim = linear.in_features
|
| 45 |
+
out_dim = linear.out_features
|
| 46 |
+
self.r = r
|
| 47 |
+
self.scale = alpha / r
|
| 48 |
+
|
| 49 |
+
self.lora_A = nn.Parameter(torch.empty(r, in_dim))
|
| 50 |
+
self.lora_B = nn.Parameter(torch.zeros(out_dim, r))
|
| 51 |
+
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
|
| 52 |
+
|
| 53 |
+
self.lora_dropout = nn.Dropout(p=dropout) if dropout > 0.0 else nn.Identity()
|
| 54 |
+
|
| 55 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 56 |
+
base_out = self.base(x)
|
| 57 |
+
lora_out = self.lora_dropout(x) @ self.lora_A.T @ self.lora_B.T
|
| 58 |
+
return base_out + self.scale * lora_out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _apply_lora_to_clip(
|
| 62 |
+
clip_encoder: nn.Module,
|
| 63 |
+
lora_r: int,
|
| 64 |
+
lora_alpha: int,
|
| 65 |
+
lora_dropout: float,
|
| 66 |
+
) -> nn.Module:
|
| 67 |
+
"""
|
| 68 |
+
CLIPClassifier.vision_model 의 모든 encoder layer에서
|
| 69 |
+
q_proj / v_proj 를 LoRALinear 로 교체한다 (in-place).
|
| 70 |
+
|
| 71 |
+
구조: clip_encoder.vision_model.vision_model.encoder.layers
|
| 72 |
+
"""
|
| 73 |
+
encoder_layers = clip_encoder.vision_model.vision_model.encoder.layers
|
| 74 |
+
for layer in encoder_layers:
|
| 75 |
+
attn = layer.self_attn
|
| 76 |
+
attn.q_proj = LoRALinear(attn.q_proj, r=lora_r, alpha=lora_alpha, dropout=lora_dropout)
|
| 77 |
+
attn.v_proj = LoRALinear(attn.v_proj, r=lora_r, alpha=lora_alpha, dropout=lora_dropout)
|
| 78 |
+
return clip_encoder
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _apply_lora_to_vit(
|
| 82 |
+
vit_encoder: nn.Module,
|
| 83 |
+
lora_r: int,
|
| 84 |
+
lora_alpha: int,
|
| 85 |
+
lora_dropout: float,
|
| 86 |
+
) -> nn.Module:
|
| 87 |
+
"""
|
| 88 |
+
ViTClassifier.backbone 의 모든 encoder layer에서
|
| 89 |
+
MLP 레이어를 LoRALinear 로 교체한다 (in-place).
|
| 90 |
+
|
| 91 |
+
torchvision ViT 구조:
|
| 92 |
+
backbone.encoder.layers.encoder_layer_N.mlp[0] (768→3072)
|
| 93 |
+
backbone.encoder.layers.encoder_layer_N.mlp[3] (3072→768)
|
| 94 |
+
|
| 95 |
+
Note: torchvision ViT의 MultiheadAttention은 out_proj.weight를 직접 접근
|
| 96 |
+
(F.multi_head_attention_forward)하므로 out_proj는 교체 불가 — MLP에만 적용.
|
| 97 |
+
"""
|
| 98 |
+
for layer in vit_encoder.backbone.encoder.layers:
|
| 99 |
+
mlp = layer.mlp
|
| 100 |
+
mlp[0] = LoRALinear(mlp[0], r=lora_r, alpha=lora_alpha, dropout=lora_dropout)
|
| 101 |
+
mlp[3] = LoRALinear(mlp[3], r=lora_r, alpha=lora_alpha, dropout=lora_dropout)
|
| 102 |
+
return vit_encoder
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class LoRAHybridClassifier(HybridClassifier):
|
| 106 |
+
"""
|
| 107 |
+
CLIP + ViT 양쪽에 LoRA를 적용한 HybridClassifier.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
lora_r: LoRA rank (기본 16)
|
| 111 |
+
lora_alpha: LoRA scaling factor (기본 32)
|
| 112 |
+
lora_dropout: LoRA dropout (기본 0.05)
|
| 113 |
+
lora_vit: ViT에도 LoRA 적용 여부 (기본 True)
|
| 114 |
+
+ HybridClassifier 의 모든 인자
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def __init__(
|
| 118 |
+
self,
|
| 119 |
+
lora_r: int = 16,
|
| 120 |
+
lora_alpha: int = 32,
|
| 121 |
+
lora_dropout: float = 0.05,
|
| 122 |
+
lora_vit: bool = True,
|
| 123 |
+
# HybridClassifier 기본 인자
|
| 124 |
+
vit_backbone: str = "vit_b_16",
|
| 125 |
+
clip_backbone: str = "clip-vit-b-32",
|
| 126 |
+
fusion: str = "concat",
|
| 127 |
+
num_classes: int = 2,
|
| 128 |
+
freeze_vit: bool = True,
|
| 129 |
+
freeze_clip: bool = True,
|
| 130 |
+
vit_pretrained: bool = True,
|
| 131 |
+
dropout: float = 0.1,
|
| 132 |
+
):
|
| 133 |
+
super().__init__(
|
| 134 |
+
vit_backbone=vit_backbone,
|
| 135 |
+
clip_backbone=clip_backbone,
|
| 136 |
+
fusion=fusion,
|
| 137 |
+
num_classes=num_classes,
|
| 138 |
+
freeze_vit=freeze_vit,
|
| 139 |
+
freeze_clip=freeze_clip,
|
| 140 |
+
vit_pretrained=vit_pretrained,
|
| 141 |
+
dropout=dropout,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
self.lora_r = lora_r
|
| 145 |
+
self.lora_alpha = lora_alpha
|
| 146 |
+
self.lora_vit = lora_vit
|
| 147 |
+
|
| 148 |
+
# CLIP에 LoRA 주입
|
| 149 |
+
_apply_lora_to_clip(self.clip_encoder, lora_r, lora_alpha, lora_dropout)
|
| 150 |
+
|
| 151 |
+
# ViT에도 LoRA 주입 (base 가중치는 이미 동결됨)
|
| 152 |
+
if lora_vit:
|
| 153 |
+
_apply_lora_to_vit(self.vit_encoder, lora_r, lora_alpha, lora_dropout)
|
| 154 |
+
|
| 155 |
+
def param_summary(self) -> str:
|
| 156 |
+
trainable = self.trainable_params()
|
| 157 |
+
total = self.total_params()
|
| 158 |
+
vit_tag = "+ViT" if self.lora_vit else ""
|
| 159 |
+
return (
|
| 160 |
+
f"[Hybrid+LoRA{vit_tag}: {self.vit_backbone_name} + {self.clip_backbone_name} "
|
| 161 |
+
f"r={self.lora_r} α={self.lora_alpha}] "
|
| 162 |
+
f"전체 {total:,} | 학습 가능 {trainable:,} "
|
| 163 |
+
f"({100 * trainable / total:.1f}%)"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def lora_param_count(self) -> int:
|
| 167 |
+
"""LoRA 어댑터 가중치만의 파라미터 수."""
|
| 168 |
+
count = 0
|
| 169 |
+
for name, p in self.named_parameters():
|
| 170 |
+
if "lora_" in name and p.requires_grad:
|
| 171 |
+
count += p.numel()
|
| 172 |
+
return count
|
src/models/lora_hybrid_fft.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LoRA Hybrid + FFT Branch 분류기.
|
| 3 |
+
|
| 4 |
+
기존 LoRAHybridClassifier에 주파수 도메인 분기(FFTBranch)를 추가.
|
| 5 |
+
|
| 6 |
+
구조:
|
| 7 |
+
ViT-B/16 (768) LoRA 동결 or 미세조정
|
| 8 |
+
CLIP ViT-B/32 (512) LoRA 동결 or 미세조정
|
| 9 |
+
FFT branch (256) 새로 학습
|
| 10 |
+
──────────────────────────────────────────
|
| 11 |
+
concat → (1536) → MLP head → 2 classes
|
| 12 |
+
|
| 13 |
+
학습 전략 (2단계):
|
| 14 |
+
Stage 1: FFT branch + 새 head만 학습 (LoRA 동결)
|
| 15 |
+
→ 기존 ViT/CLIP 표현 손상 없이 FFT 수렴
|
| 16 |
+
Stage 2: 전체 LoRA + FFT + head 학습 (lr 낮게)
|
| 17 |
+
→ 통합 최적화
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
|
| 25 |
+
from .lora_hybrid import LoRAHybridClassifier
|
| 26 |
+
from .fft_branch import FFTBranch
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _build_mlp_head(in_dim: int, num_classes: int, dropout: float) -> nn.Sequential:
|
| 30 |
+
hidden = in_dim // 2
|
| 31 |
+
head = nn.Sequential(
|
| 32 |
+
nn.LayerNorm(in_dim),
|
| 33 |
+
nn.Linear(in_dim, hidden),
|
| 34 |
+
nn.GELU(),
|
| 35 |
+
nn.Dropout(p=dropout),
|
| 36 |
+
nn.Linear(hidden, num_classes),
|
| 37 |
+
)
|
| 38 |
+
nn.init.trunc_normal_(head[-1].weight, std=0.02)
|
| 39 |
+
nn.init.zeros_(head[-1].bias)
|
| 40 |
+
return head
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LoRAHybridFFTClassifier(LoRAHybridClassifier):
|
| 44 |
+
"""
|
| 45 |
+
ViT(LoRA) + CLIP(LoRA) + FFT Branch 삼중 분류기.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
fft_dim: FFT branch 출력 차원 (기본 256)
|
| 49 |
+
fft_dropout: FFT branch 드롭아웃
|
| 50 |
+
+ LoRAHybridClassifier 의 모든 인자
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
fft_dim: int = 256,
|
| 56 |
+
fft_dropout: float = 0.1,
|
| 57 |
+
# LoRA 인자
|
| 58 |
+
lora_r: int = 32,
|
| 59 |
+
lora_alpha: int = 64,
|
| 60 |
+
lora_dropout: float = 0.1,
|
| 61 |
+
lora_vit: bool = True,
|
| 62 |
+
# Hybrid 인자
|
| 63 |
+
vit_backbone: str = "vit_b_16",
|
| 64 |
+
clip_backbone: str = "clip-vit-b-32",
|
| 65 |
+
fusion: str = "concat",
|
| 66 |
+
num_classes: int = 2,
|
| 67 |
+
freeze_vit: bool = True,
|
| 68 |
+
freeze_clip: bool = True,
|
| 69 |
+
vit_pretrained: bool = True,
|
| 70 |
+
dropout: float = 0.3,
|
| 71 |
+
):
|
| 72 |
+
super().__init__(
|
| 73 |
+
lora_r=lora_r,
|
| 74 |
+
lora_alpha=lora_alpha,
|
| 75 |
+
lora_dropout=lora_dropout,
|
| 76 |
+
lora_vit=lora_vit,
|
| 77 |
+
vit_backbone=vit_backbone,
|
| 78 |
+
clip_backbone=clip_backbone,
|
| 79 |
+
fusion=fusion,
|
| 80 |
+
num_classes=num_classes,
|
| 81 |
+
freeze_vit=freeze_vit,
|
| 82 |
+
freeze_clip=freeze_clip,
|
| 83 |
+
vit_pretrained=vit_pretrained,
|
| 84 |
+
dropout=dropout,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
self.fft_dim = fft_dim
|
| 88 |
+
|
| 89 |
+
# FFT branch 추가 (새로 학습할 파라미터)
|
| 90 |
+
self.fft_branch = FFTBranch(
|
| 91 |
+
out_dim=fft_dim,
|
| 92 |
+
dropout=fft_dropout,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# 기존 head(1280 → 2)를 새 head(1280+256=1536 → 2)로 교체
|
| 96 |
+
new_fused_dim = self.fused_dim + fft_dim # 1280 + 256 = 1536
|
| 97 |
+
self.head = _build_mlp_head(new_fused_dim, num_classes, dropout)
|
| 98 |
+
self.fused_dim = new_fused_dim
|
| 99 |
+
|
| 100 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 101 |
+
"""
|
| 102 |
+
ViT + CLIP + FFT 특징을 concat.
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
(B, 1536)
|
| 106 |
+
"""
|
| 107 |
+
vit_feat = self.vit_encoder.forward_features(x) # (B, 768)
|
| 108 |
+
clip_feat = self.clip_encoder.forward_features(x) # (B, 512)
|
| 109 |
+
fft_feat = self.fft_branch(x) # (B, 256)
|
| 110 |
+
return torch.cat([vit_feat, clip_feat, fft_feat], dim=-1) # (B, 1536)
|
| 111 |
+
|
| 112 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 113 |
+
return self.head(self.forward_features(x))
|
| 114 |
+
|
| 115 |
+
def freeze_lora(self):
|
| 116 |
+
"""Stage 1: LoRA 가중치 동결, FFT branch + head만 학습."""
|
| 117 |
+
for name, param in self.named_parameters():
|
| 118 |
+
if "lora_" in name:
|
| 119 |
+
param.requires_grad_(False)
|
| 120 |
+
# FFT branch와 head는 항상 학습
|
| 121 |
+
for param in self.fft_branch.parameters():
|
| 122 |
+
param.requires_grad_(True)
|
| 123 |
+
for param in self.head.parameters():
|
| 124 |
+
param.requires_grad_(True)
|
| 125 |
+
|
| 126 |
+
def unfreeze_lora(self):
|
| 127 |
+
"""Stage 2: LoRA 가중치 해제, 전체 학습."""
|
| 128 |
+
for name, param in self.named_parameters():
|
| 129 |
+
if "lora_" in name:
|
| 130 |
+
param.requires_grad_(True)
|
| 131 |
+
|
| 132 |
+
def load_lora_weights(self, checkpoint_path: str, device: str = "cpu"):
|
| 133 |
+
"""
|
| 134 |
+
기존 LoRAHybridClassifier 체크포인트에서 ViT/CLIP/LoRA 가중치만 로드.
|
| 135 |
+
FFT branch와 새 head는 랜덤 초기화 유지.
|
| 136 |
+
"""
|
| 137 |
+
import torch
|
| 138 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=True)
|
| 139 |
+
state = ckpt.get("model_state_dict", ckpt)
|
| 140 |
+
|
| 141 |
+
# FFT branch / head 키 제외하고 로드
|
| 142 |
+
own_state = self.state_dict()
|
| 143 |
+
loaded, skipped = 0, 0
|
| 144 |
+
for k, v in state.items():
|
| 145 |
+
if k.startswith("fft_branch.") or k.startswith("head."):
|
| 146 |
+
skipped += 1
|
| 147 |
+
continue
|
| 148 |
+
if k in own_state and own_state[k].shape == v.shape:
|
| 149 |
+
own_state[k] = v
|
| 150 |
+
loaded += 1
|
| 151 |
+
else:
|
| 152 |
+
skipped += 1
|
| 153 |
+
|
| 154 |
+
self.load_state_dict(own_state, strict=False)
|
| 155 |
+
print(f" [FFT모델 로드] {loaded}개 키 로드, {skipped}개 skip (fft/head 제외)")
|
| 156 |
+
|
| 157 |
+
def param_summary(self) -> str:
|
| 158 |
+
trainable = self.trainable_params()
|
| 159 |
+
total = self.total_params()
|
| 160 |
+
fft_params = sum(p.numel() for p in self.fft_branch.parameters())
|
| 161 |
+
return (
|
| 162 |
+
f"[Hybrid+LoRA+FFT: {self.vit_backbone_name} + {self.clip_backbone_name} "
|
| 163 |
+
f"r={self.lora_r} α={self.lora_alpha}] "
|
| 164 |
+
f"전체 {total:,} | 학습 가능 {trainable:,} ({100*trainable/total:.1f}%) "
|
| 165 |
+
f"| FFT branch {fft_params:,}"
|
| 166 |
+
)
|
src/models/srm_branch.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""High-pass residual branch for AI-image forensic cues.
|
| 2 |
+
|
| 3 |
+
The branch uses a small fixed SRM-style high-pass filter bank, then learns a
|
| 4 |
+
compact CNN over the residual maps. It is intentionally light so it can be
|
| 5 |
+
added to the existing ViT+CLIP+LoRA hybrid within 8GB VRAM.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CLIP_MEAN = (0.48145466, 0.4578275, 0.40821073)
|
| 15 |
+
CLIP_STD = (0.26862954, 0.26130258, 0.27577711)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _make_highpass_bank() -> torch.Tensor:
|
| 19 |
+
"""Return fixed 5x5 high-pass kernels repeated per RGB channel."""
|
| 20 |
+
filters = []
|
| 21 |
+
|
| 22 |
+
laplace_3 = torch.tensor(
|
| 23 |
+
[
|
| 24 |
+
[0, 0, 0, 0, 0],
|
| 25 |
+
[0, -1, -1, -1, 0],
|
| 26 |
+
[0, -1, 8, -1, 0],
|
| 27 |
+
[0, -1, -1, -1, 0],
|
| 28 |
+
[0, 0, 0, 0, 0],
|
| 29 |
+
],
|
| 30 |
+
dtype=torch.float32,
|
| 31 |
+
) / 8.0
|
| 32 |
+
|
| 33 |
+
laplace_5 = torch.tensor(
|
| 34 |
+
[
|
| 35 |
+
[-1, 2, -2, 2, -1],
|
| 36 |
+
[2, -6, 8, -6, 2],
|
| 37 |
+
[-2, 8, -12, 8, -2],
|
| 38 |
+
[2, -6, 8, -6, 2],
|
| 39 |
+
[-1, 2, -2, 2, -1],
|
| 40 |
+
],
|
| 41 |
+
dtype=torch.float32,
|
| 42 |
+
) / 12.0
|
| 43 |
+
|
| 44 |
+
horizontal = torch.tensor(
|
| 45 |
+
[
|
| 46 |
+
[0, 0, 0, 0, 0],
|
| 47 |
+
[0, 0, 0, 0, 0],
|
| 48 |
+
[0, -1, 2, -1, 0],
|
| 49 |
+
[0, 0, 0, 0, 0],
|
| 50 |
+
[0, 0, 0, 0, 0],
|
| 51 |
+
],
|
| 52 |
+
dtype=torch.float32,
|
| 53 |
+
) / 2.0
|
| 54 |
+
|
| 55 |
+
vertical = horizontal.t().contiguous()
|
| 56 |
+
|
| 57 |
+
diagonal = torch.tensor(
|
| 58 |
+
[
|
| 59 |
+
[-1, 0, 0, 0, 1],
|
| 60 |
+
[0, -1, 0, 1, 0],
|
| 61 |
+
[0, 0, 0, 0, 0],
|
| 62 |
+
[0, 1, 0, -1, 0],
|
| 63 |
+
[1, 0, 0, 0, -1],
|
| 64 |
+
],
|
| 65 |
+
dtype=torch.float32,
|
| 66 |
+
) / 4.0
|
| 67 |
+
|
| 68 |
+
anti_diagonal = torch.flip(diagonal, dims=[1])
|
| 69 |
+
base_filters = [laplace_3, laplace_5, horizontal, vertical, diagonal, anti_diagonal]
|
| 70 |
+
|
| 71 |
+
weight = torch.zeros(len(base_filters) * 3, 3, 5, 5, dtype=torch.float32)
|
| 72 |
+
out_idx = 0
|
| 73 |
+
for channel in range(3):
|
| 74 |
+
for kernel in base_filters:
|
| 75 |
+
weight[out_idx, channel] = kernel
|
| 76 |
+
out_idx += 1
|
| 77 |
+
return weight
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class SRMBranch(nn.Module):
|
| 81 |
+
"""Fixed high-pass residual filters followed by a compact trainable CNN."""
|
| 82 |
+
|
| 83 |
+
def __init__(self, out_dim: int = 128, dropout: float = 0.1):
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.out_dim = out_dim
|
| 86 |
+
|
| 87 |
+
self.register_buffer("clip_mean", torch.tensor(CLIP_MEAN).view(1, 3, 1, 1))
|
| 88 |
+
self.register_buffer("clip_std", torch.tensor(CLIP_STD).view(1, 3, 1, 1))
|
| 89 |
+
|
| 90 |
+
self.highpass = nn.Conv2d(
|
| 91 |
+
in_channels=3,
|
| 92 |
+
out_channels=18,
|
| 93 |
+
kernel_size=5,
|
| 94 |
+
padding=2,
|
| 95 |
+
bias=False,
|
| 96 |
+
)
|
| 97 |
+
with torch.no_grad():
|
| 98 |
+
self.highpass.weight.copy_(_make_highpass_bank())
|
| 99 |
+
self.highpass.weight.requires_grad_(False)
|
| 100 |
+
|
| 101 |
+
self.encoder = nn.Sequential(
|
| 102 |
+
nn.Conv2d(18, 48, kernel_size=3, stride=2, padding=1, bias=False),
|
| 103 |
+
nn.BatchNorm2d(48),
|
| 104 |
+
nn.GELU(),
|
| 105 |
+
nn.Conv2d(48, 96, kernel_size=3, stride=2, padding=1, bias=False),
|
| 106 |
+
nn.BatchNorm2d(96),
|
| 107 |
+
nn.GELU(),
|
| 108 |
+
nn.Conv2d(96, 128, kernel_size=3, stride=2, padding=1, bias=False),
|
| 109 |
+
nn.BatchNorm2d(128),
|
| 110 |
+
nn.GELU(),
|
| 111 |
+
nn.Conv2d(128, 128, kernel_size=3, stride=2, padding=1, bias=False),
|
| 112 |
+
nn.BatchNorm2d(128),
|
| 113 |
+
nn.GELU(),
|
| 114 |
+
)
|
| 115 |
+
self.pool = nn.AdaptiveAvgPool2d(1)
|
| 116 |
+
self.proj = nn.Sequential(
|
| 117 |
+
nn.Dropout(p=dropout),
|
| 118 |
+
nn.Linear(128, out_dim),
|
| 119 |
+
nn.LayerNorm(out_dim),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
self._init_trainable_weights()
|
| 123 |
+
|
| 124 |
+
def _init_trainable_weights(self) -> None:
|
| 125 |
+
for module in self.modules():
|
| 126 |
+
if module is self.highpass:
|
| 127 |
+
continue
|
| 128 |
+
if isinstance(module, nn.Conv2d):
|
| 129 |
+
nn.init.kaiming_normal_(module.weight, mode="fan_out", nonlinearity="relu")
|
| 130 |
+
elif isinstance(module, nn.BatchNorm2d):
|
| 131 |
+
nn.init.ones_(module.weight)
|
| 132 |
+
nn.init.zeros_(module.bias)
|
| 133 |
+
elif isinstance(module, nn.Linear):
|
| 134 |
+
nn.init.trunc_normal_(module.weight, std=0.02)
|
| 135 |
+
if module.bias is not None:
|
| 136 |
+
nn.init.zeros_(module.bias)
|
| 137 |
+
|
| 138 |
+
def _to_raw_rgb(self, x: torch.Tensor) -> torch.Tensor:
|
| 139 |
+
# Training inputs are CLIP-normalized. For residual filters, use the
|
| 140 |
+
# image-like range so filters see sensor/compression residue rather than
|
| 141 |
+
# normalization offsets.
|
| 142 |
+
return (x * self.clip_std + self.clip_mean).clamp(0.0, 1.0)
|
| 143 |
+
|
| 144 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 145 |
+
raw = self._to_raw_rgb(x)
|
| 146 |
+
residual = self.highpass(raw)
|
| 147 |
+
feat = self.encoder(residual)
|
| 148 |
+
feat = self.pool(feat).flatten(1)
|
| 149 |
+
return self.proj(feat)
|
src/models/vit_classifier.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ViT 기반 이진 분류기.
|
| 3 |
+
|
| 4 |
+
지원 백본 (torchvision):
|
| 5 |
+
vit_b_16 ViT-Base / patch16 / hidden 768 (~86M params)
|
| 6 |
+
vit_b_32 ViT-Base / patch32 / hidden 768 (~88M params)
|
| 7 |
+
vit_l_16 ViT-Large / patch16 / hidden 1024 (~307M params)
|
| 8 |
+
|
| 9 |
+
핵심 설계:
|
| 10 |
+
- torchvision 백본의 heads 레이어를 Identity로 교체
|
| 11 |
+
- backbone(x) 가 CLS 토큰 특징벡터 (B, hidden_dim) 를 반환하게 만듦
|
| 12 |
+
- 그 위에 커스텀 분류 헤드 추가
|
| 13 |
+
- forward_features() 로 특징벡터를 별도 추출 가능 (XAI 연결 준비)
|
| 14 |
+
|
| 15 |
+
사용:
|
| 16 |
+
model = ViTClassifier("vit_b_16", pretrained=True)
|
| 17 |
+
logits = model(images) # (B, 2)
|
| 18 |
+
feats = model.forward_features(images) # (B, 768)
|
| 19 |
+
probs = model.predict_proba(images) # (B, 2)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from typing import Callable
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn as nn
|
| 29 |
+
from torchvision.models import (
|
| 30 |
+
vit_b_16, ViT_B_16_Weights,
|
| 31 |
+
vit_b_32, ViT_B_32_Weights,
|
| 32 |
+
vit_l_16, ViT_L_16_Weights,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ──────────────────────────────────────────────
|
| 37 |
+
# 백본 레지스트리
|
| 38 |
+
# ──────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class _BackboneSpec:
|
| 42 |
+
builder: Callable # torchvision 모델 생성 함수
|
| 43 |
+
weights: object # ImageNet 사전학습 가중치
|
| 44 |
+
hidden_dim: int # CLS 토큰 차원
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
_BACKBONE_REGISTRY: dict[str, _BackboneSpec] = {
|
| 48 |
+
"vit_b_16": _BackboneSpec(vit_b_16, ViT_B_16_Weights.IMAGENET1K_V1, 768),
|
| 49 |
+
"vit_b_32": _BackboneSpec(vit_b_32, ViT_B_32_Weights.IMAGENET1K_V1, 768),
|
| 50 |
+
"vit_l_16": _BackboneSpec(vit_l_16, ViT_L_16_Weights.IMAGENET1K_V1, 1024),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
AVAILABLE_BACKBONES = list(_BACKBONE_REGISTRY.keys())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _build_backbone(name: str, pretrained: bool) -> tuple[nn.Module, int]:
|
| 57 |
+
"""
|
| 58 |
+
백본을 생성하고 heads를 Identity로 교체한다.
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
(backbone, hidden_dim)
|
| 62 |
+
backbone(x) 는 CLS 특징벡터 (B, hidden_dim) 를 반환한다.
|
| 63 |
+
"""
|
| 64 |
+
if name not in _BACKBONE_REGISTRY:
|
| 65 |
+
raise ValueError(
|
| 66 |
+
f"지원하지 않는 백본: '{name}'\n"
|
| 67 |
+
f"사용 가능: {AVAILABLE_BACKBONES}"
|
| 68 |
+
)
|
| 69 |
+
spec = _BACKBONE_REGISTRY[name]
|
| 70 |
+
weights = spec.weights if pretrained else None
|
| 71 |
+
backbone = spec.builder(weights=weights)
|
| 72 |
+
|
| 73 |
+
# 원본 heads(1000-class linear)를 Identity로 교체
|
| 74 |
+
# → backbone.forward() 가 x[:, 0] (CLS 토큰) 을 그대로 반환하게 됨
|
| 75 |
+
backbone.heads = nn.Identity()
|
| 76 |
+
|
| 77 |
+
return backbone, spec.hidden_dim
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ──────────────────────────────────────────────
|
| 81 |
+
# 분류기
|
| 82 |
+
# ──────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
class ViTClassifier(nn.Module):
|
| 85 |
+
"""
|
| 86 |
+
ViT 기반 real/fake 이진 분류기.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
backbone_name: 백본 이름 (vit_b_16 | vit_b_32 | vit_l_16)
|
| 90 |
+
num_classes: 출력 클래스 수 (기본 2)
|
| 91 |
+
pretrained: ImageNet 사전학습 가중치 사용
|
| 92 |
+
freeze_backbone: True면 백본 동결, 헤드만 학습
|
| 93 |
+
dropout: 분류 헤드 Dropout 비율
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
def __init__(
|
| 97 |
+
self,
|
| 98 |
+
backbone_name: str = "vit_b_16",
|
| 99 |
+
num_classes: int = 2,
|
| 100 |
+
pretrained: bool = True,
|
| 101 |
+
freeze_backbone: bool = False,
|
| 102 |
+
dropout: float = 0.1,
|
| 103 |
+
):
|
| 104 |
+
super().__init__()
|
| 105 |
+
|
| 106 |
+
self.backbone_name = backbone_name
|
| 107 |
+
self.num_classes = num_classes
|
| 108 |
+
|
| 109 |
+
self.backbone, self.hidden_dim = _build_backbone(backbone_name, pretrained)
|
| 110 |
+
|
| 111 |
+
if freeze_backbone:
|
| 112 |
+
for param in self.backbone.parameters():
|
| 113 |
+
param.requires_grad = False
|
| 114 |
+
|
| 115 |
+
# 분류 헤드: LayerNorm → Dropout → Linear
|
| 116 |
+
self.head = nn.Sequential(
|
| 117 |
+
nn.LayerNorm(self.hidden_dim),
|
| 118 |
+
nn.Dropout(p=dropout),
|
| 119 |
+
nn.Linear(self.hidden_dim, num_classes),
|
| 120 |
+
)
|
| 121 |
+
self._init_head()
|
| 122 |
+
|
| 123 |
+
def _init_head(self):
|
| 124 |
+
linear = self.head[-1]
|
| 125 |
+
nn.init.trunc_normal_(linear.weight, std=0.02)
|
| 126 |
+
nn.init.zeros_(linear.bias)
|
| 127 |
+
|
| 128 |
+
# ── Forward ──────────────────────────────────
|
| 129 |
+
|
| 130 |
+
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
|
| 131 |
+
"""
|
| 132 |
+
이미지 배치 → CLS 토큰 특징벡터 (B, hidden_dim).
|
| 133 |
+
|
| 134 |
+
XAI, 특징 시각화, 임베딩 추출 등에 사용.
|
| 135 |
+
내부적으로 torchvision ViT의 원본 forward 경로를 그대로 사용:
|
| 136 |
+
patch_embed → pos_embed → encoder → CLS token
|
| 137 |
+
"""
|
| 138 |
+
return self.backbone(x) # Identity heads 덕분에 CLS 벡터 반��
|
| 139 |
+
|
| 140 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 141 |
+
"""logits (B, num_classes) 반환."""
|
| 142 |
+
return self.head(self.forward_features(x))
|
| 143 |
+
|
| 144 |
+
@torch.no_grad()
|
| 145 |
+
def predict_proba(self, x: torch.Tensor) -> torch.Tensor:
|
| 146 |
+
"""Softmax 확률 (B, num_classes) 반환. 추론 전용."""
|
| 147 |
+
self.eval()
|
| 148 |
+
return torch.softmax(self(x), dim=-1)
|
| 149 |
+
|
| 150 |
+
# ── 파라미터 정보 ─────────────────────────────
|
| 151 |
+
|
| 152 |
+
def trainable_params(self) -> int:
|
| 153 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 154 |
+
|
| 155 |
+
def total_params(self) -> int:
|
| 156 |
+
return sum(p.numel() for p in self.parameters())
|
| 157 |
+
|
| 158 |
+
def param_summary(self) -> str:
|
| 159 |
+
trainable = self.trainable_params()
|
| 160 |
+
total = self.total_params()
|
| 161 |
+
return (
|
| 162 |
+
f"[{self.backbone_name}] "
|
| 163 |
+
f"전체 {total:,} | 학습 가능 {trainable:,} "
|
| 164 |
+
f"({100 * trainable / total:.1f}%)"
|
| 165 |
+
)
|
src/training/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .trainer import Trainer, EarlyStopping
|
| 2 |
+
from .metrics import compute_metrics, format_metrics
|
src/training/metrics.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
분류 성능 지표 계산.
|
| 3 |
+
|
| 4 |
+
출력 형식 (dict):
|
| 5 |
+
{
|
| 6 |
+
"loss": float,
|
| 7 |
+
"accuracy": float,
|
| 8 |
+
"precision": float,
|
| 9 |
+
"recall": float,
|
| 10 |
+
"f1": float,
|
| 11 |
+
"confusion_matrix": [[TP, FP], [FN, TN]] 형식 2x2 리스트
|
| 12 |
+
}
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import List
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from sklearn.metrics import (
|
| 21 |
+
accuracy_score,
|
| 22 |
+
precision_score,
|
| 23 |
+
recall_score,
|
| 24 |
+
f1_score,
|
| 25 |
+
confusion_matrix,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def compute_metrics(
|
| 30 |
+
preds: List[int] | np.ndarray,
|
| 31 |
+
labels: List[int] | np.ndarray,
|
| 32 |
+
loss: float = 0.0,
|
| 33 |
+
pos_label: int = 1,
|
| 34 |
+
) -> dict:
|
| 35 |
+
"""
|
| 36 |
+
예측값과 정답 라벨로 분류 지표를 계산한다.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
preds: 모델 예측 클래스 (0 또는 1)
|
| 40 |
+
labels: 정답 라벨
|
| 41 |
+
loss: 해당 epoch/step의 평균 loss
|
| 42 |
+
pos_label: positive 클래스 번호 (fake=1)
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
지표 딕셔너리
|
| 46 |
+
"""
|
| 47 |
+
preds = np.array(preds)
|
| 48 |
+
labels = np.array(labels)
|
| 49 |
+
|
| 50 |
+
acc = accuracy_score(labels, preds)
|
| 51 |
+
prec = precision_score(labels, preds, pos_label=pos_label,
|
| 52 |
+
zero_division=0, average="binary")
|
| 53 |
+
rec = recall_score(labels, preds, pos_label=pos_label,
|
| 54 |
+
zero_division=0, average="binary")
|
| 55 |
+
f1 = f1_score(labels, preds, pos_label=pos_label,
|
| 56 |
+
zero_division=0, average="binary")
|
| 57 |
+
cm = confusion_matrix(labels, preds, labels=[0, 1]).tolist()
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"loss": round(loss, 6),
|
| 61 |
+
"accuracy": round(acc, 6),
|
| 62 |
+
"precision": round(prec, 6),
|
| 63 |
+
"recall": round(rec, 6),
|
| 64 |
+
"f1": round(f1, 6),
|
| 65 |
+
"confusion_matrix": cm,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def compute_metrics_from_probs(
|
| 70 |
+
prob_fake: List[float] | np.ndarray,
|
| 71 |
+
labels: List[int] | np.ndarray,
|
| 72 |
+
threshold: float = 0.5,
|
| 73 |
+
loss: float = 0.0,
|
| 74 |
+
) -> dict:
|
| 75 |
+
"""
|
| 76 |
+
fake(1) probability and threshold based binary metrics.
|
| 77 |
+
|
| 78 |
+
This keeps the default 0.5 behavior available while allowing validation-set
|
| 79 |
+
calibration to reduce real->fake false positives or balance both classes.
|
| 80 |
+
"""
|
| 81 |
+
prob_fake = np.asarray(prob_fake, dtype=np.float32)
|
| 82 |
+
labels = np.asarray(labels)
|
| 83 |
+
preds = (prob_fake >= threshold).astype(np.int64)
|
| 84 |
+
|
| 85 |
+
metrics = compute_metrics(preds, labels, loss=loss)
|
| 86 |
+
cm = np.asarray(metrics["confusion_matrix"], dtype=np.int64)
|
| 87 |
+
real_total = max(int(cm[0].sum()), 1)
|
| 88 |
+
fake_total = max(int(cm[1].sum()), 1)
|
| 89 |
+
real_acc = float(cm[0, 0] / real_total)
|
| 90 |
+
fake_acc = float(cm[1, 1] / fake_total)
|
| 91 |
+
fpr = float(cm[0, 1] / real_total)
|
| 92 |
+
fnr = float(cm[1, 0] / fake_total)
|
| 93 |
+
macro_f1 = float(f1_score(labels, preds, average="macro", zero_division=0))
|
| 94 |
+
balanced_accuracy = (real_acc + fake_acc) / 2.0
|
| 95 |
+
worst_class_accuracy = min(real_acc, fake_acc)
|
| 96 |
+
error_balance_penalty = abs(fpr - fnr)
|
| 97 |
+
deployment_score = (
|
| 98 |
+
0.45 * balanced_accuracy
|
| 99 |
+
+ 0.35 * macro_f1
|
| 100 |
+
+ 0.20 * worst_class_accuracy
|
| 101 |
+
- 0.20 * error_balance_penalty
|
| 102 |
+
)
|
| 103 |
+
real_guarded_score = (
|
| 104 |
+
0.40 * balanced_accuracy
|
| 105 |
+
+ 0.25 * macro_f1
|
| 106 |
+
+ 0.25 * real_acc
|
| 107 |
+
+ 0.10 * worst_class_accuracy
|
| 108 |
+
- 0.25 * fpr
|
| 109 |
+
- 0.10 * error_balance_penalty
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
metrics.update({
|
| 113 |
+
"threshold": round(float(threshold), 6),
|
| 114 |
+
"real_accuracy": round(real_acc, 6),
|
| 115 |
+
"fake_accuracy": round(fake_acc, 6),
|
| 116 |
+
"balanced_accuracy": round(balanced_accuracy, 6),
|
| 117 |
+
"worst_class_accuracy": round(worst_class_accuracy, 6),
|
| 118 |
+
"macro_f1": round(macro_f1, 6),
|
| 119 |
+
"fpr": round(fpr, 6),
|
| 120 |
+
"fnr": round(fnr, 6),
|
| 121 |
+
"error_balance_gap": round(error_balance_penalty, 6),
|
| 122 |
+
"deployment_score": round(deployment_score, 6),
|
| 123 |
+
"real_guarded_score": round(real_guarded_score, 6),
|
| 124 |
+
})
|
| 125 |
+
return metrics
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def find_best_threshold(
|
| 129 |
+
prob_fake: List[float] | np.ndarray,
|
| 130 |
+
labels: List[int] | np.ndarray,
|
| 131 |
+
min_threshold: float = 0.05,
|
| 132 |
+
max_threshold: float = 0.95,
|
| 133 |
+
step: float = 0.005,
|
| 134 |
+
objective: str = "macro_f1",
|
| 135 |
+
max_fpr: float | None = None,
|
| 136 |
+
) -> dict:
|
| 137 |
+
"""Sweep thresholds and return the best metrics.
|
| 138 |
+
|
| 139 |
+
When max_fpr is provided, prefer candidates that satisfy the real->fake
|
| 140 |
+
false-positive constraint. If no threshold can satisfy it, fall back to the
|
| 141 |
+
unconstrained optimum and mark the result so the caller can see the miss.
|
| 142 |
+
"""
|
| 143 |
+
best = None
|
| 144 |
+
best_unconstrained = None
|
| 145 |
+
for threshold in np.arange(min_threshold, max_threshold + 1e-12, step):
|
| 146 |
+
metrics = compute_metrics_from_probs(
|
| 147 |
+
prob_fake=prob_fake,
|
| 148 |
+
labels=labels,
|
| 149 |
+
threshold=float(threshold),
|
| 150 |
+
)
|
| 151 |
+
if objective not in metrics:
|
| 152 |
+
raise ValueError(
|
| 153 |
+
f"Unknown threshold objective '{objective}'. "
|
| 154 |
+
f"Available keys: {sorted(metrics.keys())}"
|
| 155 |
+
)
|
| 156 |
+
if best_unconstrained is None or metrics[objective] > best_unconstrained[objective]:
|
| 157 |
+
best_unconstrained = metrics
|
| 158 |
+
if max_fpr is not None and metrics["fpr"] > max_fpr:
|
| 159 |
+
continue
|
| 160 |
+
if best is None or metrics[objective] > best[objective]:
|
| 161 |
+
best = metrics
|
| 162 |
+
constraint_satisfied = best is not None
|
| 163 |
+
if best is None:
|
| 164 |
+
best = best_unconstrained
|
| 165 |
+
if best is not None:
|
| 166 |
+
best["threshold_max_fpr"] = None if max_fpr is None else round(float(max_fpr), 6)
|
| 167 |
+
best["threshold_fpr_constraint_satisfied"] = bool(constraint_satisfied)
|
| 168 |
+
return best
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def format_metrics(metrics: dict, prefix: str = "") -> str:
|
| 172 |
+
"""지표를 한 줄 문자열로 포맷팅."""
|
| 173 |
+
p = f"{prefix} " if prefix else ""
|
| 174 |
+
return (
|
| 175 |
+
f"{p}loss={metrics['loss']:.4f} "
|
| 176 |
+
f"acc={metrics['accuracy']:.4f} "
|
| 177 |
+
f"prec={metrics['precision']:.4f} "
|
| 178 |
+
f"rec={metrics['recall']:.4f} "
|
| 179 |
+
f"f1={metrics['f1']:.4f}"
|
| 180 |
+
)
|
src/training/seed_utils.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cudnn 재현성 / 성능 모드 설정.
|
| 3 |
+
|
| 4 |
+
두 모드:
|
| 5 |
+
reproducible (재현성 우선):
|
| 6 |
+
benchmark=False, deterministic=True
|
| 7 |
+
→ 동일 seed + 동일 환경이면 결과가 완전히 재현됨
|
| 8 |
+
→ 속도 약 10~30% 저하 (가변 알고리즘 비활성화)
|
| 9 |
+
|
| 10 |
+
performance (성능 우선, 기본값):
|
| 11 |
+
benchmark=True (CUDA), deterministic=False
|
| 12 |
+
→ cuDNN 이 입력 크기에 맞는 최적 커널을 자동 선택
|
| 13 |
+
→ 재현성 보장 안 됨 (비결정적 알고리즘 허용)
|
| 14 |
+
|
| 15 |
+
Note:
|
| 16 |
+
benchmark=True + deterministic=True 조합은 PyTorch 에서 지원하지 않음.
|
| 17 |
+
(공식 권고: 두 옵션은 상호 배타적으로 사용)
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def configure_cudnn(reproducible: bool) -> dict:
|
| 25 |
+
"""
|
| 26 |
+
cudnn 설정을 적용하고 실제 적용된 값을 반환한다.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
reproducible: True → 재현성 우선 / False → 성능 우선
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
{"benchmark": bool, "deterministic": bool, "cuda_available": bool}
|
| 33 |
+
"""
|
| 34 |
+
has_cuda = torch.cuda.is_available()
|
| 35 |
+
|
| 36 |
+
if reproducible:
|
| 37 |
+
benchmark = False
|
| 38 |
+
deterministic = True
|
| 39 |
+
else:
|
| 40 |
+
# CUDA 없으면 benchmark 옵션 자체가 무의미하므로 False
|
| 41 |
+
benchmark = has_cuda
|
| 42 |
+
deterministic = False
|
| 43 |
+
|
| 44 |
+
torch.backends.cudnn.benchmark = benchmark
|
| 45 |
+
torch.backends.cudnn.deterministic = deterministic
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
"benchmark": benchmark,
|
| 49 |
+
"deterministic": deterministic,
|
| 50 |
+
"cuda_available": has_cuda,
|
| 51 |
+
}
|
src/training/trainer.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
학습/평가 루프 + 체크포인트 저장/로드 + Early Stopping.
|
| 3 |
+
|
| 4 |
+
사용 예:
|
| 5 |
+
trainer = Trainer(model, optimizer, scheduler, device, cfg)
|
| 6 |
+
for epoch in range(cfg["training"]["epochs"]):
|
| 7 |
+
train_m = trainer.train_one_epoch(train_loader, epoch)
|
| 8 |
+
val_m = trainer.evaluate(val_loader, epoch)
|
| 9 |
+
if trainer.early_stopping(val_m["loss"], epoch):
|
| 10 |
+
break
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
from torch.utils.data import DataLoader
|
| 23 |
+
|
| 24 |
+
from .metrics import (
|
| 25 |
+
compute_metrics,
|
| 26 |
+
compute_metrics_from_probs,
|
| 27 |
+
find_best_threshold,
|
| 28 |
+
format_metrics,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FocalCrossEntropyLoss(nn.Module):
|
| 33 |
+
"""
|
| 34 |
+
Per-sample focal cross entropy for hard real/fake examples.
|
| 35 |
+
|
| 36 |
+
Returns a (B,) tensor so the existing OHEM and mean reduction paths keep
|
| 37 |
+
working exactly like CrossEntropyLoss(reduction="none").
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
gamma: float = 1.5,
|
| 43 |
+
weight: Optional[torch.Tensor] = None,
|
| 44 |
+
label_smoothing: float = 0.0,
|
| 45 |
+
):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.gamma = gamma
|
| 48 |
+
self.label_smoothing = label_smoothing
|
| 49 |
+
if weight is not None:
|
| 50 |
+
self.register_buffer("weight", weight)
|
| 51 |
+
else:
|
| 52 |
+
self.weight = None
|
| 53 |
+
|
| 54 |
+
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
log_probs = F.log_softmax(logits, dim=-1)
|
| 56 |
+
log_pt = log_probs.gather(1, targets[:, None]).squeeze(1)
|
| 57 |
+
pt = log_pt.exp().clamp(min=1e-6, max=1.0)
|
| 58 |
+
ce = F.cross_entropy(
|
| 59 |
+
logits,
|
| 60 |
+
targets,
|
| 61 |
+
weight=self.weight,
|
| 62 |
+
label_smoothing=self.label_smoothing,
|
| 63 |
+
reduction="none",
|
| 64 |
+
)
|
| 65 |
+
return ((1.0 - pt) ** self.gamma) * ce
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class EarlyStopping:
|
| 69 |
+
"""
|
| 70 |
+
val_loss가 patience 에폭 동안 개선되지 않으면 학습 중단.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
patience: 허용 에폭 수
|
| 74 |
+
min_delta: 개선으로 인정하는 최소 감소량
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
def __init__(self, patience: int = 5, min_delta: float = 1e-4):
|
| 78 |
+
self.patience = patience
|
| 79 |
+
self.min_delta = min_delta
|
| 80 |
+
self.best_loss = float("inf")
|
| 81 |
+
self.counter = 0
|
| 82 |
+
self.should_stop = False
|
| 83 |
+
|
| 84 |
+
def __call__(self, val_loss: float) -> bool:
|
| 85 |
+
if val_loss < self.best_loss - self.min_delta:
|
| 86 |
+
self.best_loss = val_loss
|
| 87 |
+
self.counter = 0
|
| 88 |
+
else:
|
| 89 |
+
self.counter += 1
|
| 90 |
+
if self.counter >= self.patience:
|
| 91 |
+
self.should_stop = True
|
| 92 |
+
return self.should_stop
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class Trainer:
|
| 96 |
+
"""
|
| 97 |
+
ViTClassifier 학습/평가/저장을 담당하는 클래스.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
model: nn.Module (ViTClassifier 등)
|
| 101 |
+
optimizer: torch optimizer
|
| 102 |
+
scheduler: lr scheduler (None 허용)
|
| 103 |
+
device: torch.device
|
| 104 |
+
checkpoint_dir: 체크포인트 저장 경로
|
| 105 |
+
mixed_precision: AMP 사용 여부
|
| 106 |
+
patience: EarlyStopping patience
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
def __init__(
|
| 110 |
+
self,
|
| 111 |
+
model: nn.Module,
|
| 112 |
+
optimizer: torch.optim.Optimizer,
|
| 113 |
+
scheduler=None,
|
| 114 |
+
device: torch.device = torch.device("cpu"),
|
| 115 |
+
checkpoint_dir: str = "artifacts/checkpoints",
|
| 116 |
+
mixed_precision: bool = False,
|
| 117 |
+
patience: int = 5,
|
| 118 |
+
label_smoothing: float = 0.0,
|
| 119 |
+
class_weights: Optional[list] = None,
|
| 120 |
+
loss_type: str = "ce",
|
| 121 |
+
focal_gamma: float = 1.5,
|
| 122 |
+
ohem_ratio: float = 0.0,
|
| 123 |
+
monitor_metric: str = "loss",
|
| 124 |
+
monitor_mode: str = "min",
|
| 125 |
+
checkpoint_calibrated: bool = False,
|
| 126 |
+
threshold_objective: str = "macro_f1",
|
| 127 |
+
threshold_min: float = 0.05,
|
| 128 |
+
threshold_max: float = 0.95,
|
| 129 |
+
threshold_step: float = 0.005,
|
| 130 |
+
threshold_max_fpr: float | None = None,
|
| 131 |
+
max_epoch_checkpoints: int | None = None,
|
| 132 |
+
aux_loss_weight: float = 0.0,
|
| 133 |
+
aux_loss_branch_weights: Optional[dict] = None,
|
| 134 |
+
group_dro_weight: float = 0.0,
|
| 135 |
+
):
|
| 136 |
+
self.model = model.to(device)
|
| 137 |
+
self.optimizer = optimizer
|
| 138 |
+
self.scheduler = scheduler
|
| 139 |
+
self.device = device
|
| 140 |
+
self.checkpoint_dir = Path(checkpoint_dir)
|
| 141 |
+
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
|
| 143 |
+
weight_tensor = None
|
| 144 |
+
if class_weights is not None:
|
| 145 |
+
weight_tensor = torch.tensor(class_weights, dtype=torch.float32).to(device)
|
| 146 |
+
print(f" [클래스 가중치] real={class_weights[0]:.4f}, fake={class_weights[1]:.4f}")
|
| 147 |
+
self.criterion = nn.CrossEntropyLoss(
|
| 148 |
+
label_smoothing=label_smoothing,
|
| 149 |
+
weight=weight_tensor,
|
| 150 |
+
reduction="none", # OHEM을 위해 sample별 loss 필요
|
| 151 |
+
)
|
| 152 |
+
self.label_smoothing = label_smoothing
|
| 153 |
+
self.weight_tensor = weight_tensor
|
| 154 |
+
self.ohem_ratio = 0.0 # 0.0 = OHEM 비활성, 0.7 = 상위 70% hard sample만 사용
|
| 155 |
+
loss_type = loss_type.lower()
|
| 156 |
+
if loss_type == "focal":
|
| 157 |
+
self.criterion = FocalCrossEntropyLoss(
|
| 158 |
+
gamma=focal_gamma,
|
| 159 |
+
weight=weight_tensor,
|
| 160 |
+
label_smoothing=label_smoothing,
|
| 161 |
+
)
|
| 162 |
+
print(f" [loss] focal cross entropy gamma={focal_gamma:.3f}")
|
| 163 |
+
elif loss_type == "ce":
|
| 164 |
+
print(" [loss] cross entropy")
|
| 165 |
+
else:
|
| 166 |
+
raise ValueError(f"Unsupported loss_type: {loss_type}")
|
| 167 |
+
self.ohem_ratio = ohem_ratio
|
| 168 |
+
|
| 169 |
+
self.scaler = torch.amp.GradScaler(enabled=mixed_precision)
|
| 170 |
+
self.mixed_precision = mixed_precision
|
| 171 |
+
self.early_stopping = EarlyStopping(patience=patience)
|
| 172 |
+
|
| 173 |
+
self.best_val_loss = float("inf")
|
| 174 |
+
self.monitor_metric = monitor_metric
|
| 175 |
+
self.monitor_mode = monitor_mode.lower()
|
| 176 |
+
self.checkpoint_calibrated = checkpoint_calibrated
|
| 177 |
+
self.threshold_objective = threshold_objective
|
| 178 |
+
self.threshold_min = threshold_min
|
| 179 |
+
self.threshold_max = threshold_max
|
| 180 |
+
self.threshold_step = threshold_step
|
| 181 |
+
self.threshold_max_fpr = threshold_max_fpr
|
| 182 |
+
self.max_epoch_checkpoints = max_epoch_checkpoints
|
| 183 |
+
self.aux_loss_weight = float(aux_loss_weight)
|
| 184 |
+
self.aux_loss_branch_weights = (
|
| 185 |
+
{str(name): float(weight) for name, weight in aux_loss_branch_weights.items()}
|
| 186 |
+
if aux_loss_branch_weights
|
| 187 |
+
else None
|
| 188 |
+
)
|
| 189 |
+
self.group_dro_weight = max(0.0, min(1.0, float(group_dro_weight)))
|
| 190 |
+
if self.group_dro_weight > 0:
|
| 191 |
+
print(f" [loss] group DRO worst-source weight={self.group_dro_weight:.3f}")
|
| 192 |
+
self._last_eval_probs = []
|
| 193 |
+
self._last_eval_labels = []
|
| 194 |
+
if self.monitor_mode not in ("min", "max"):
|
| 195 |
+
raise ValueError(f"monitor_mode must be 'min' or 'max', got {monitor_mode}")
|
| 196 |
+
self.best_monitor_value = (
|
| 197 |
+
float("inf") if self.monitor_mode == "min" else -float("inf")
|
| 198 |
+
)
|
| 199 |
+
self.best_epoch = -1
|
| 200 |
+
# 대시보드용 실시간 진행 파일 (checkpoint_dir/../progress.json)
|
| 201 |
+
self._progress_file = self.checkpoint_dir.parent / "progress.json"
|
| 202 |
+
|
| 203 |
+
def _forward_model(self, images: torch.Tensor, want_aux: bool = False):
|
| 204 |
+
use_aux = self.aux_loss_weight > 0 or bool(self.aux_loss_branch_weights)
|
| 205 |
+
if want_aux and use_aux and hasattr(self.model, "forward_with_aux"):
|
| 206 |
+
return self.model.forward_with_aux(images)
|
| 207 |
+
return self.model(images)
|
| 208 |
+
|
| 209 |
+
@staticmethod
|
| 210 |
+
def _main_logits(output) -> torch.Tensor:
|
| 211 |
+
if isinstance(output, dict):
|
| 212 |
+
return output["logits"]
|
| 213 |
+
return output
|
| 214 |
+
|
| 215 |
+
@staticmethod
|
| 216 |
+
def _unpack_batch(batch):
|
| 217 |
+
if len(batch) == 3:
|
| 218 |
+
images, labels, groups = batch
|
| 219 |
+
return images, labels, groups
|
| 220 |
+
images, labels = batch
|
| 221 |
+
return images, labels, None
|
| 222 |
+
|
| 223 |
+
def _loss_from_output(
|
| 224 |
+
self,
|
| 225 |
+
output,
|
| 226 |
+
labels: torch.Tensor,
|
| 227 |
+
groups: torch.Tensor | None = None,
|
| 228 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 229 |
+
logits = self._main_logits(output)
|
| 230 |
+
sample_loss = self.criterion(logits, labels)
|
| 231 |
+
if self.ohem_ratio > 0.0:
|
| 232 |
+
k = max(1, int(sample_loss.numel() * self.ohem_ratio))
|
| 233 |
+
primary_loss = sample_loss.topk(k).values.mean()
|
| 234 |
+
else:
|
| 235 |
+
primary_loss = sample_loss.mean()
|
| 236 |
+
|
| 237 |
+
loss = primary_loss
|
| 238 |
+
if self.group_dro_weight > 0 and groups is not None:
|
| 239 |
+
groups = groups.to(labels.device, non_blocking=True)
|
| 240 |
+
group_losses = []
|
| 241 |
+
for group_id in torch.unique(groups):
|
| 242 |
+
mask = groups == group_id
|
| 243 |
+
if bool(mask.any()):
|
| 244 |
+
group_losses.append(sample_loss[mask].mean())
|
| 245 |
+
if group_losses:
|
| 246 |
+
worst_group_loss = torch.stack(group_losses).max()
|
| 247 |
+
loss = (
|
| 248 |
+
(1.0 - self.group_dro_weight) * primary_loss
|
| 249 |
+
+ self.group_dro_weight * worst_group_loss
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
if isinstance(output, dict) and isinstance(output.get("aux_logits"), dict):
|
| 253 |
+
aux_losses = []
|
| 254 |
+
for name, aux_logits in output["aux_logits"].items():
|
| 255 |
+
branch_weight = 1.0
|
| 256 |
+
if self.aux_loss_branch_weights is not None:
|
| 257 |
+
branch_weight = self.aux_loss_branch_weights.get(name, 0.0)
|
| 258 |
+
if branch_weight > 0:
|
| 259 |
+
aux_losses.append(self.criterion(aux_logits, labels).mean() * branch_weight)
|
| 260 |
+
if aux_losses:
|
| 261 |
+
loss = loss + self.aux_loss_weight * torch.stack(aux_losses).mean()
|
| 262 |
+
return logits, loss
|
| 263 |
+
|
| 264 |
+
# ──────────────────────────────────────────
|
| 265 |
+
# 학습 루프
|
| 266 |
+
# ──────────────────────────────────────────
|
| 267 |
+
|
| 268 |
+
def train_one_epoch(self, loader: DataLoader, epoch: int) -> dict:
|
| 269 |
+
"""
|
| 270 |
+
1 에폭 학습.
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
train 지표 딕셔너리
|
| 274 |
+
"""
|
| 275 |
+
self.model.train()
|
| 276 |
+
total_loss = 0.0
|
| 277 |
+
all_preds, all_labels = [], []
|
| 278 |
+
t0 = time.time()
|
| 279 |
+
|
| 280 |
+
_data_time = 0.0
|
| 281 |
+
_compute_time = 0.0
|
| 282 |
+
_t_batch_start = time.time()
|
| 283 |
+
|
| 284 |
+
for step, batch in enumerate(loader):
|
| 285 |
+
images, labels, groups = self._unpack_batch(batch)
|
| 286 |
+
_data_time += time.time() - _t_batch_start
|
| 287 |
+
|
| 288 |
+
_t_compute = time.time()
|
| 289 |
+
images = images.to(self.device, non_blocking=True)
|
| 290 |
+
labels = labels.to(self.device, non_blocking=True)
|
| 291 |
+
|
| 292 |
+
self.optimizer.zero_grad()
|
| 293 |
+
|
| 294 |
+
with torch.amp.autocast(
|
| 295 |
+
device_type=self.device.type,
|
| 296 |
+
enabled=self.mixed_precision,
|
| 297 |
+
):
|
| 298 |
+
output = self._forward_model(images, want_aux=True)
|
| 299 |
+
logits, loss = self._loss_from_output(output, labels, groups=groups)
|
| 300 |
+
|
| 301 |
+
self.scaler.scale(loss).backward()
|
| 302 |
+
self.scaler.unscale_(self.optimizer)
|
| 303 |
+
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
|
| 304 |
+
self.scaler.step(self.optimizer)
|
| 305 |
+
self.scaler.update()
|
| 306 |
+
_compute_time += time.time() - _t_compute
|
| 307 |
+
|
| 308 |
+
total_loss += loss.item()
|
| 309 |
+
preds = logits.argmax(dim=-1)
|
| 310 |
+
all_preds.extend(preds.cpu().tolist())
|
| 311 |
+
all_labels.extend(labels.cpu().tolist())
|
| 312 |
+
|
| 313 |
+
if step % 10 == 0:
|
| 314 |
+
_avg_data = _data_time / (step + 1)
|
| 315 |
+
_avg_comp = _compute_time / (step + 1)
|
| 316 |
+
print(
|
| 317 |
+
f" [E{epoch+1}] step {step+1}/{len(loader)} "
|
| 318 |
+
f"loss={loss.item():.4f} "
|
| 319 |
+
f"data={_avg_data*1000:.0f}ms compute={_avg_comp*1000:.0f}ms",
|
| 320 |
+
end="\r",
|
| 321 |
+
)
|
| 322 |
+
# 대시보드용 실시간 progress 기록 (매 10 step)
|
| 323 |
+
try:
|
| 324 |
+
import json as _json
|
| 325 |
+
self._progress_file.write_text(_json.dumps({
|
| 326 |
+
"epoch": epoch + 1,
|
| 327 |
+
"step": step + 1,
|
| 328 |
+
"total_steps": len(loader),
|
| 329 |
+
"loss": round(loss.item(), 4),
|
| 330 |
+
"phase": "train",
|
| 331 |
+
"data_ms": round(_avg_data * 1000, 1),
|
| 332 |
+
"compute_ms": round(_avg_comp * 1000, 1),
|
| 333 |
+
}), encoding="utf-8")
|
| 334 |
+
except Exception:
|
| 335 |
+
pass
|
| 336 |
+
|
| 337 |
+
_t_batch_start = time.time()
|
| 338 |
+
|
| 339 |
+
if self.scheduler is not None:
|
| 340 |
+
self.scheduler.step()
|
| 341 |
+
|
| 342 |
+
avg_loss = total_loss / len(loader)
|
| 343 |
+
metrics = compute_metrics(all_preds, all_labels, loss=avg_loss)
|
| 344 |
+
elapsed = time.time() - t0
|
| 345 |
+
print(f"\n [E{epoch+1} TRAIN] {format_metrics(metrics)} ({elapsed:.1f}s)")
|
| 346 |
+
return metrics
|
| 347 |
+
|
| 348 |
+
# ──────────────────────────────────────────
|
| 349 |
+
# 평가 루프
|
| 350 |
+
# ──────────────────────────────────────────
|
| 351 |
+
|
| 352 |
+
@torch.no_grad()
|
| 353 |
+
def evaluate(
|
| 354 |
+
self,
|
| 355 |
+
loader: DataLoader,
|
| 356 |
+
epoch: int,
|
| 357 |
+
tag: str = "VAL",
|
| 358 |
+
threshold: float = 0.5,
|
| 359 |
+
) -> dict:
|
| 360 |
+
"""
|
| 361 |
+
검증/테스트 평가.
|
| 362 |
+
|
| 363 |
+
Returns:
|
| 364 |
+
평가 지표 딕셔너리
|
| 365 |
+
"""
|
| 366 |
+
self.model.eval()
|
| 367 |
+
total_loss = 0.0
|
| 368 |
+
all_probs, all_labels = [], []
|
| 369 |
+
all_groups = []
|
| 370 |
+
|
| 371 |
+
for batch in loader:
|
| 372 |
+
images, labels, _groups = self._unpack_batch(batch)
|
| 373 |
+
images = images.to(self.device, non_blocking=True)
|
| 374 |
+
labels = labels.to(self.device, non_blocking=True)
|
| 375 |
+
|
| 376 |
+
with torch.amp.autocast(
|
| 377 |
+
device_type=self.device.type,
|
| 378 |
+
enabled=self.mixed_precision,
|
| 379 |
+
):
|
| 380 |
+
logits = self._main_logits(self._forward_model(images, want_aux=False))
|
| 381 |
+
loss = self.criterion(logits, labels).mean()
|
| 382 |
+
|
| 383 |
+
total_loss += loss.item()
|
| 384 |
+
prob_fake = torch.softmax(logits.float(), dim=-1)[:, 1]
|
| 385 |
+
all_probs.extend(prob_fake.cpu().tolist())
|
| 386 |
+
all_labels.extend(labels.cpu().tolist())
|
| 387 |
+
if _groups is not None:
|
| 388 |
+
all_groups.extend(_groups.cpu().tolist())
|
| 389 |
+
|
| 390 |
+
avg_loss = total_loss / len(loader)
|
| 391 |
+
self._last_eval_probs = all_probs
|
| 392 |
+
self._last_eval_labels = all_labels
|
| 393 |
+
metrics = compute_metrics_from_probs(
|
| 394 |
+
all_probs,
|
| 395 |
+
all_labels,
|
| 396 |
+
threshold=threshold,
|
| 397 |
+
loss=avg_loss,
|
| 398 |
+
)
|
| 399 |
+
if all_groups and len(all_groups) == len(all_labels):
|
| 400 |
+
reverse_group_map = {}
|
| 401 |
+
dataset = getattr(loader, "dataset", None)
|
| 402 |
+
group_to_id = getattr(dataset, "group_to_id", None)
|
| 403 |
+
if isinstance(group_to_id, dict):
|
| 404 |
+
reverse_group_map = {int(value): str(key) for key, value in group_to_id.items()}
|
| 405 |
+
source_metrics = {}
|
| 406 |
+
for group_id in sorted(set(int(group) for group in all_groups)):
|
| 407 |
+
indices = [idx for idx, group in enumerate(all_groups) if int(group) == group_id]
|
| 408 |
+
if not indices:
|
| 409 |
+
continue
|
| 410 |
+
name = reverse_group_map.get(group_id, f"group_{group_id}")
|
| 411 |
+
source_metrics[name] = {
|
| 412 |
+
"count": len(indices),
|
| 413 |
+
**compute_metrics_from_probs(
|
| 414 |
+
[all_probs[idx] for idx in indices],
|
| 415 |
+
[all_labels[idx] for idx in indices],
|
| 416 |
+
threshold=threshold,
|
| 417 |
+
),
|
| 418 |
+
}
|
| 419 |
+
metrics["source_metrics"] = source_metrics
|
| 420 |
+
print(f" [E{epoch+1} {tag} ] {format_metrics(metrics)} thr={threshold:.3f}")
|
| 421 |
+
return metrics
|
| 422 |
+
|
| 423 |
+
@torch.no_grad()
|
| 424 |
+
def calibrate_threshold(
|
| 425 |
+
self,
|
| 426 |
+
loader: DataLoader,
|
| 427 |
+
objective: str = "macro_f1",
|
| 428 |
+
min_threshold: float = 0.05,
|
| 429 |
+
max_threshold: float = 0.95,
|
| 430 |
+
step: float = 0.005,
|
| 431 |
+
) -> dict:
|
| 432 |
+
"""Find the best fake-probability threshold on a validation loader."""
|
| 433 |
+
self.model.eval()
|
| 434 |
+
all_probs, all_labels = [], []
|
| 435 |
+
|
| 436 |
+
for batch in loader:
|
| 437 |
+
images, labels, _groups = self._unpack_batch(batch)
|
| 438 |
+
images = images.to(self.device, non_blocking=True)
|
| 439 |
+
with torch.amp.autocast(
|
| 440 |
+
device_type=self.device.type,
|
| 441 |
+
enabled=self.mixed_precision,
|
| 442 |
+
):
|
| 443 |
+
logits = self._main_logits(self._forward_model(images, want_aux=False))
|
| 444 |
+
prob_fake = torch.softmax(logits.float(), dim=-1)[:, 1]
|
| 445 |
+
all_probs.extend(prob_fake.cpu().tolist())
|
| 446 |
+
all_labels.extend(labels.tolist())
|
| 447 |
+
|
| 448 |
+
metrics = find_best_threshold(
|
| 449 |
+
all_probs,
|
| 450 |
+
all_labels,
|
| 451 |
+
min_threshold=min_threshold,
|
| 452 |
+
max_threshold=max_threshold,
|
| 453 |
+
step=step,
|
| 454 |
+
objective=objective,
|
| 455 |
+
max_fpr=self.threshold_max_fpr,
|
| 456 |
+
)
|
| 457 |
+
print(
|
| 458 |
+
f" [THRESHOLD] objective={objective} best={metrics['threshold']:.3f} "
|
| 459 |
+
f"macro_f1={metrics['macro_f1']:.4f} "
|
| 460 |
+
f"bal_acc={metrics['balanced_accuracy']:.4f} "
|
| 461 |
+
f"fpr={metrics['fpr']:.4f} fnr={metrics['fnr']:.4f}"
|
| 462 |
+
)
|
| 463 |
+
return metrics
|
| 464 |
+
|
| 465 |
+
# ──────────────────────────────────────────
|
| 466 |
+
# 체크포인트
|
| 467 |
+
# ──────────────────────────────────────────
|
| 468 |
+
|
| 469 |
+
def save_checkpoint(
|
| 470 |
+
self,
|
| 471 |
+
epoch: int,
|
| 472 |
+
metrics: dict,
|
| 473 |
+
filename: Optional[str] = None,
|
| 474 |
+
) -> Path:
|
| 475 |
+
"""
|
| 476 |
+
체크포인트 저장.
|
| 477 |
+
|
| 478 |
+
파일명이 없으면 "best.pt"를 덮어쓴다.
|
| 479 |
+
val_loss가 최고 기록을 갱신할 때 "best.pt"를 저장하는 용도로 설계됨.
|
| 480 |
+
"""
|
| 481 |
+
if filename is None:
|
| 482 |
+
filename = "best.pt"
|
| 483 |
+
path = self.checkpoint_dir / filename
|
| 484 |
+
|
| 485 |
+
torch.save(
|
| 486 |
+
{
|
| 487 |
+
"epoch": epoch,
|
| 488 |
+
"model_state_dict": self.model.state_dict(),
|
| 489 |
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
| 490 |
+
"scheduler_state_dict": (
|
| 491 |
+
self.scheduler.state_dict() if self.scheduler else None
|
| 492 |
+
),
|
| 493 |
+
"metrics": metrics,
|
| 494 |
+
},
|
| 495 |
+
path,
|
| 496 |
+
)
|
| 497 |
+
print(f" [저장] {path} (val_loss={metrics.get('loss', '?'):.4f})")
|
| 498 |
+
return path
|
| 499 |
+
|
| 500 |
+
def load_checkpoint(
|
| 501 |
+
self,
|
| 502 |
+
path: str,
|
| 503 |
+
load_optimizer: bool = True,
|
| 504 |
+
load_scheduler: bool = True,
|
| 505 |
+
) -> dict:
|
| 506 |
+
"""
|
| 507 |
+
체크포인트 로드. 저장된 metrics 딕셔너리를 반환한다.
|
| 508 |
+
"""
|
| 509 |
+
ckpt = torch.load(path, map_location=self.device, weights_only=True)
|
| 510 |
+
self.model.load_state_dict(ckpt["model_state_dict"])
|
| 511 |
+
if load_optimizer and ckpt.get("optimizer_state_dict"):
|
| 512 |
+
self.optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 513 |
+
if load_scheduler and self.scheduler and ckpt.get("scheduler_state_dict"):
|
| 514 |
+
self.scheduler.load_state_dict(ckpt["scheduler_state_dict"])
|
| 515 |
+
epoch = ckpt.get("epoch", -1)
|
| 516 |
+
metrics = ckpt.get("metrics", {})
|
| 517 |
+
print(f" [로드] {path} epoch={epoch} metrics={metrics}")
|
| 518 |
+
return metrics
|
| 519 |
+
|
| 520 |
+
def _prune_epoch_checkpoints(self) -> None:
|
| 521 |
+
if not self.max_epoch_checkpoints or self.max_epoch_checkpoints <= 0:
|
| 522 |
+
return
|
| 523 |
+
epoch_files = sorted(
|
| 524 |
+
self.checkpoint_dir.glob("epoch_*.pt"),
|
| 525 |
+
key=lambda path: path.stat().st_mtime,
|
| 526 |
+
)
|
| 527 |
+
excess = len(epoch_files) - self.max_epoch_checkpoints
|
| 528 |
+
if excess <= 0:
|
| 529 |
+
return
|
| 530 |
+
for path in epoch_files[:excess]:
|
| 531 |
+
try:
|
| 532 |
+
path.unlink()
|
| 533 |
+
print(f" [prune] removed old checkpoint {path.name}")
|
| 534 |
+
except OSError as exc:
|
| 535 |
+
print(f" [prune warning] could not remove {path.name}: {exc}")
|
| 536 |
+
|
| 537 |
+
# ──────────────────────────────────────────
|
| 538 |
+
# 통합 루프 헬퍼
|
| 539 |
+
# ──────────────────────────────────────────
|
| 540 |
+
|
| 541 |
+
def fit(
|
| 542 |
+
self,
|
| 543 |
+
train_loader: DataLoader,
|
| 544 |
+
val_loader: DataLoader,
|
| 545 |
+
epochs: int,
|
| 546 |
+
) -> list[dict]:
|
| 547 |
+
"""
|
| 548 |
+
전체 학습 루프.
|
| 549 |
+
|
| 550 |
+
Returns:
|
| 551 |
+
에폭별 {'train': {...}, 'val': {...}} 기록 리스트
|
| 552 |
+
"""
|
| 553 |
+
history = []
|
| 554 |
+
|
| 555 |
+
for epoch in range(epochs):
|
| 556 |
+
print(f"\n{'─'*60}")
|
| 557 |
+
train_m = self.train_one_epoch(train_loader, epoch)
|
| 558 |
+
val_m = self.evaluate(val_loader, epoch, tag="VAL")
|
| 559 |
+
|
| 560 |
+
if self.checkpoint_calibrated:
|
| 561 |
+
calibrated_m = find_best_threshold(
|
| 562 |
+
self._last_eval_probs,
|
| 563 |
+
self._last_eval_labels,
|
| 564 |
+
min_threshold=self.threshold_min,
|
| 565 |
+
max_threshold=self.threshold_max,
|
| 566 |
+
step=self.threshold_step,
|
| 567 |
+
objective=self.threshold_objective,
|
| 568 |
+
max_fpr=self.threshold_max_fpr,
|
| 569 |
+
)
|
| 570 |
+
for key, value in calibrated_m.items():
|
| 571 |
+
val_m[f"calibrated_{key}"] = value
|
| 572 |
+
print(
|
| 573 |
+
f" [CAL VAL] best_thr={calibrated_m['threshold']:.3f} "
|
| 574 |
+
f"deploy={calibrated_m['deployment_score']:.6f} "
|
| 575 |
+
f"macro_f1={calibrated_m['macro_f1']:.6f} "
|
| 576 |
+
f"real_acc={calibrated_m['real_accuracy']:.6f} "
|
| 577 |
+
f"fake_acc={calibrated_m['fake_accuracy']:.6f}"
|
| 578 |
+
)
|
| 579 |
+
|
| 580 |
+
monitor_value = val_m.get(self.monitor_metric)
|
| 581 |
+
if monitor_value is None:
|
| 582 |
+
raise KeyError(
|
| 583 |
+
f"monitor_metric '{self.monitor_metric}' not found in val metrics: "
|
| 584 |
+
f"{sorted(val_m.keys())}"
|
| 585 |
+
)
|
| 586 |
+
is_best = (
|
| 587 |
+
monitor_value < self.best_monitor_value
|
| 588 |
+
if self.monitor_mode == "min"
|
| 589 |
+
else monitor_value > self.best_monitor_value
|
| 590 |
+
)
|
| 591 |
+
if is_best:
|
| 592 |
+
self.best_monitor_value = monitor_value
|
| 593 |
+
self.best_val_loss = val_m["loss"]
|
| 594 |
+
self.best_epoch = epoch
|
| 595 |
+
self.save_checkpoint(epoch, val_m, filename="best.pt")
|
| 596 |
+
print(
|
| 597 |
+
f" [best] {self.monitor_metric}={monitor_value:.6f} "
|
| 598 |
+
f"mode={self.monitor_mode}"
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
# 에폭 체크포인트 (매 에폭)
|
| 602 |
+
self.save_checkpoint(epoch, val_m, filename=f"epoch_{epoch+1:03d}.pt")
|
| 603 |
+
self._prune_epoch_checkpoints()
|
| 604 |
+
|
| 605 |
+
history.append({"epoch": epoch + 1, "train": train_m, "val": val_m})
|
| 606 |
+
try:
|
| 607 |
+
import json as _json
|
| 608 |
+
|
| 609 |
+
history_path = self.checkpoint_dir.parent / "history.json"
|
| 610 |
+
history_path.write_text(
|
| 611 |
+
_json.dumps(history, ensure_ascii=False, indent=2),
|
| 612 |
+
encoding="utf-8",
|
| 613 |
+
)
|
| 614 |
+
except Exception:
|
| 615 |
+
pass
|
| 616 |
+
|
| 617 |
+
# Early stopping
|
| 618 |
+
if self.early_stopping(val_m["loss"]):
|
| 619 |
+
print(
|
| 620 |
+
f"\n[Early Stopping] {self.early_stopping.patience} 에폭 개선 없음."
|
| 621 |
+
f" best epoch={self.best_epoch+1}"
|
| 622 |
+
)
|
| 623 |
+
break
|
| 624 |
+
|
| 625 |
+
print(
|
| 626 |
+
f"\n학습 완료. best epoch={self.best_epoch+1} "
|
| 627 |
+
f"best {self.monitor_metric}={self.best_monitor_value:.6f} "
|
| 628 |
+
f"best val_loss={self.best_val_loss:.4f}"
|
| 629 |
+
)
|
| 630 |
+
return history
|
src/xai/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .visual_explainer import ExplainResult, explain_image, png_b64
|
| 2 |
+
|
| 3 |
+
__all__ = ["ExplainResult", "explain_image", "png_b64"]
|
src/xai/visual_explainer.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visual explanations for A-EYE detector models.
|
| 2 |
+
|
| 3 |
+
The maps produced here are class-discriminative evidence maps. They show which
|
| 4 |
+
image regions increased the model's AI/fake logit; they are not pixel-perfect
|
| 5 |
+
manipulation masks.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import base64
|
| 11 |
+
import io
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
from PIL import Image, ImageFilter, ImageOps
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ExplainResult:
|
| 24 |
+
heatmap: Image.Image
|
| 25 |
+
overlay: Image.Image
|
| 26 |
+
meta: dict[str, Any]
|
| 27 |
+
raw_map: np.ndarray
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def png_b64(image: Image.Image) -> str:
|
| 31 |
+
buffer = io.BytesIO()
|
| 32 |
+
image.save(buffer, format="PNG")
|
| 33 |
+
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _normalize_map(cam: np.ndarray) -> np.ndarray:
|
| 37 |
+
cam = np.nan_to_num(cam.astype(np.float32), nan=0.0, posinf=0.0, neginf=0.0)
|
| 38 |
+
cam -= float(cam.min())
|
| 39 |
+
peak = float(cam.max())
|
| 40 |
+
if peak <= 1e-7:
|
| 41 |
+
return np.zeros_like(cam, dtype=np.float32)
|
| 42 |
+
return np.clip(cam / peak, 0.0, 1.0)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _last_conv(module: nn.Module | None) -> nn.Conv2d | None:
|
| 46 |
+
if module is None:
|
| 47 |
+
return None
|
| 48 |
+
for child in reversed(list(module.modules())):
|
| 49 |
+
if isinstance(child, nn.Conv2d):
|
| 50 |
+
return child
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _vit_token_target(model: nn.Module) -> nn.Module | None:
|
| 55 |
+
vit = getattr(model, "vit_encoder", None)
|
| 56 |
+
backbone = getattr(vit, "backbone", None)
|
| 57 |
+
encoder = getattr(backbone, "encoder", None)
|
| 58 |
+
layers = getattr(encoder, "layers", None)
|
| 59 |
+
if layers is None:
|
| 60 |
+
return None
|
| 61 |
+
try:
|
| 62 |
+
return layers[-1]
|
| 63 |
+
except Exception:
|
| 64 |
+
children = list(layers.children())
|
| 65 |
+
return children[-1] if children else None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _xai_targets(model: nn.Module) -> list[tuple[str, str, nn.Module]]:
|
| 69 |
+
targets: list[tuple[str, str, nn.Module]] = []
|
| 70 |
+
|
| 71 |
+
vit_target = _vit_token_target(model)
|
| 72 |
+
if vit_target is not None:
|
| 73 |
+
targets.append(("vit_patch_gradcam", "tokens", vit_target))
|
| 74 |
+
|
| 75 |
+
srm = getattr(model, "srm_branch", None)
|
| 76 |
+
srm_target = _last_conv(getattr(srm, "encoder", None))
|
| 77 |
+
if srm_target is not None:
|
| 78 |
+
targets.append(("srm_residual_gradcam", "conv", srm_target))
|
| 79 |
+
|
| 80 |
+
return targets
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _cam_from_conv(activation: torch.Tensor, gradient: torch.Tensor, size: tuple[int, int]) -> np.ndarray | None:
|
| 84 |
+
if activation.ndim != 4 or gradient.ndim != 4:
|
| 85 |
+
return None
|
| 86 |
+
weights = gradient.mean(dim=(2, 3), keepdim=True)
|
| 87 |
+
cam = F.relu((activation * weights).sum(dim=1, keepdim=True))
|
| 88 |
+
cam = F.interpolate(cam, size=size, mode="bilinear", align_corners=False)
|
| 89 |
+
return _normalize_map(cam[0, 0].detach().float().cpu().numpy())
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _cam_from_tokens(activation: torch.Tensor, gradient: torch.Tensor, size: tuple[int, int]) -> np.ndarray | None:
|
| 93 |
+
if activation.ndim != 3 or gradient.ndim != 3 or activation.shape[1] < 2:
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
tokens = activation[:, 1:, :]
|
| 97 |
+
grads = gradient[:, 1:, :]
|
| 98 |
+
patch_count = tokens.shape[1]
|
| 99 |
+
grid = int(round(patch_count ** 0.5))
|
| 100 |
+
if grid * grid != patch_count:
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
# Grad x activation works better than pure attention rollout here because it
|
| 104 |
+
# is class-discriminative for the fake logit.
|
| 105 |
+
cam = F.relu((tokens * grads).sum(dim=-1)).view(activation.shape[0], 1, grid, grid)
|
| 106 |
+
cam = F.interpolate(cam, size=size, mode="bilinear", align_corners=False)
|
| 107 |
+
return _normalize_map(cam[0, 0].detach().float().cpu().numpy())
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _input_gradient_map(tensor: torch.Tensor, size: tuple[int, int]) -> np.ndarray | None:
|
| 111 |
+
grad = tensor.grad
|
| 112 |
+
if grad is None or grad.ndim != 4:
|
| 113 |
+
return None
|
| 114 |
+
cam = grad.detach().float().abs().mean(dim=1, keepdim=True)
|
| 115 |
+
cam = F.interpolate(cam, size=size, mode="bilinear", align_corners=False)
|
| 116 |
+
return _normalize_map(cam[0, 0].cpu().numpy())
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _forward_logits(model: nn.Module, batch: torch.Tensor) -> torch.Tensor:
|
| 120 |
+
output = model(batch)
|
| 121 |
+
return output["logits"] if isinstance(output, dict) else output
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _class_margin(logits: torch.Tensor, target_class: int) -> torch.Tensor:
|
| 125 |
+
if logits.shape[-1] >= 2:
|
| 126 |
+
other = 1 - int(target_class)
|
| 127 |
+
return logits[:, int(target_class)] - logits[:, other]
|
| 128 |
+
return logits[:, int(target_class)]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@torch.no_grad()
|
| 132 |
+
def _occlusion_sensitivity_map(
|
| 133 |
+
model: nn.Module,
|
| 134 |
+
tensor: torch.Tensor,
|
| 135 |
+
size: tuple[int, int],
|
| 136 |
+
*,
|
| 137 |
+
target_class: int = 1,
|
| 138 |
+
grid: int = 7,
|
| 139 |
+
batch_size: int = 8,
|
| 140 |
+
) -> np.ndarray | None:
|
| 141 |
+
"""Patch-occlusion map: regions whose blur lowers the fake-class margin."""
|
| 142 |
+
if tensor.ndim != 4 or tensor.shape[0] != 1:
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
was_training = model.training
|
| 146 |
+
model.eval()
|
| 147 |
+
try:
|
| 148 |
+
base_logits = _forward_logits(model, tensor)
|
| 149 |
+
base_score = _class_margin(base_logits.float(), target_class)
|
| 150 |
+
_, _, height, width = tensor.shape
|
| 151 |
+
|
| 152 |
+
kernel = max(7, int(round(min(height, width) * 0.11)))
|
| 153 |
+
if kernel % 2 == 0:
|
| 154 |
+
kernel += 1
|
| 155 |
+
pad = kernel // 2
|
| 156 |
+
blurred = F.avg_pool2d(
|
| 157 |
+
F.pad(tensor, (pad, pad, pad, pad), mode="reflect"),
|
| 158 |
+
kernel_size=kernel,
|
| 159 |
+
stride=1,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
variants: list[torch.Tensor] = []
|
| 163 |
+
boxes: list[tuple[int, int, int, int]] = []
|
| 164 |
+
for gy in range(grid):
|
| 165 |
+
y0 = round(gy * height / grid)
|
| 166 |
+
y1 = round((gy + 1) * height / grid)
|
| 167 |
+
for gx in range(grid):
|
| 168 |
+
x0 = round(gx * width / grid)
|
| 169 |
+
x1 = round((gx + 1) * width / grid)
|
| 170 |
+
variant = tensor.detach().clone()
|
| 171 |
+
variant[:, :, y0:y1, x0:x1] = blurred[:, :, y0:y1, x0:x1]
|
| 172 |
+
variants.append(variant)
|
| 173 |
+
boxes.append((y0, y1, x0, x1))
|
| 174 |
+
|
| 175 |
+
drops: list[float] = []
|
| 176 |
+
for start in range(0, len(variants), batch_size):
|
| 177 |
+
batch = torch.cat(variants[start : start + batch_size], dim=0)
|
| 178 |
+
logits = _forward_logits(model, batch)
|
| 179 |
+
scores = _class_margin(logits.float(), target_class)
|
| 180 |
+
delta = (base_score - scores).detach().cpu().numpy()
|
| 181 |
+
drops.extend(float(v) for v in delta)
|
| 182 |
+
|
| 183 |
+
patch_map = torch.zeros((1, 1, height, width), device=tensor.device, dtype=torch.float32)
|
| 184 |
+
for drop, (y0, y1, x0, x1) in zip(drops, boxes):
|
| 185 |
+
if drop > 0:
|
| 186 |
+
patch_map[:, :, y0:y1, x0:x1] = max(
|
| 187 |
+
float(drop),
|
| 188 |
+
float(patch_map[:, :, y0:y1, x0:x1].max()),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
patch_map = F.interpolate(patch_map, size=size, mode="bilinear", align_corners=False)
|
| 192 |
+
arr = patch_map[0, 0].detach().cpu().numpy()
|
| 193 |
+
if float(np.max(arr)) <= 1e-7:
|
| 194 |
+
return None
|
| 195 |
+
return _normalize_map(arr)
|
| 196 |
+
finally:
|
| 197 |
+
model.train(was_training)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _fallback_residual_map(rgb: Image.Image, size: tuple[int, int]) -> np.ndarray:
|
| 201 |
+
small = rgb.resize((size[1], size[0]), Image.Resampling.BICUBIC).convert("L")
|
| 202 |
+
blur = small.filter(ImageFilter.GaussianBlur(radius=2.4))
|
| 203 |
+
arr = np.asarray(small, dtype=np.float32) / 255.0
|
| 204 |
+
low = np.asarray(blur, dtype=np.float32) / 255.0
|
| 205 |
+
return _normalize_map(np.abs(arr - low))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _object_expand(cam: np.ndarray, expansion: float = 1.0) -> np.ndarray:
|
| 209 |
+
cam = _normalize_map(cam)
|
| 210 |
+
image = Image.fromarray(np.uint8(cam * 255), "L")
|
| 211 |
+
max_size = max(3, int(round(5 + expansion * 6)))
|
| 212 |
+
if max_size % 2 == 0:
|
| 213 |
+
max_size += 1
|
| 214 |
+
expanded = image.filter(ImageFilter.MaxFilter(max_size)).filter(
|
| 215 |
+
ImageFilter.GaussianBlur(radius=2.2 + 1.7 * expansion)
|
| 216 |
+
)
|
| 217 |
+
smooth = image.filter(ImageFilter.GaussianBlur(radius=1.3 + 0.8 * expansion))
|
| 218 |
+
arr = np.asarray(expanded, dtype=np.float32) / 255.0
|
| 219 |
+
sm = np.asarray(smooth, dtype=np.float32) / 255.0
|
| 220 |
+
return _normalize_map(arr * 0.58 + sm * 0.42)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _cam_to_heatmap(cam: np.ndarray, size: tuple[int, int]) -> Image.Image:
|
| 224 |
+
cam = _normalize_map(cam)
|
| 225 |
+
if cam.shape[::-1] != size:
|
| 226 |
+
image = Image.fromarray(np.uint8(cam * 255), "L").resize(size, Image.Resampling.BICUBIC)
|
| 227 |
+
cam = np.asarray(image, dtype=np.float32) / 255.0
|
| 228 |
+
|
| 229 |
+
anchors = np.array(
|
| 230 |
+
[
|
| 231 |
+
[4, 8, 18],
|
| 232 |
+
[22, 80, 190],
|
| 233 |
+
[30, 210, 220],
|
| 234 |
+
[255, 218, 54],
|
| 235 |
+
[255, 42, 24],
|
| 236 |
+
],
|
| 237 |
+
dtype=np.float32,
|
| 238 |
+
)
|
| 239 |
+
positions = np.linspace(0.0, 1.0, len(anchors), dtype=np.float32)
|
| 240 |
+
rgb = np.zeros((*cam.shape, 3), dtype=np.float32)
|
| 241 |
+
for channel in range(3):
|
| 242 |
+
rgb[..., channel] = np.interp(cam, positions, anchors[:, channel])
|
| 243 |
+
return Image.fromarray(np.uint8(np.clip(rgb, 0, 255)), "RGB")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _overlay_heatmap(original: Image.Image, heatmap: Image.Image, alpha: float = 1.0) -> Image.Image:
|
| 247 |
+
base = original.convert("RGBA")
|
| 248 |
+
heat_rgb = heatmap.convert("RGB").resize(base.size)
|
| 249 |
+
heat_arr = np.asarray(heat_rgb, dtype=np.float32)
|
| 250 |
+
score = _normalize_map(np.maximum(heat_arr[..., 0], heat_arr[..., 1]) - heat_arr[..., 2] * 0.35)
|
| 251 |
+
focus = np.clip((score - 0.18) / 0.82, 0.0, 1.0) ** 1.35
|
| 252 |
+
alpha_arr = np.uint8(np.clip(focus * 218 * alpha, 0, 218))
|
| 253 |
+
heat = Image.fromarray(np.dstack([heat_arr.astype(np.uint8), alpha_arr]), "RGBA")
|
| 254 |
+
return Image.alpha_composite(base, heat).convert("RGB")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def explain_image(
|
| 258 |
+
model: nn.Module,
|
| 259 |
+
transform,
|
| 260 |
+
device: torch.device | str,
|
| 261 |
+
image: Image.Image,
|
| 262 |
+
*,
|
| 263 |
+
target_class: int = 1,
|
| 264 |
+
expansion: float = 1.0,
|
| 265 |
+
) -> ExplainResult:
|
| 266 |
+
rgb = ImageOps.exif_transpose(image).convert("RGB")
|
| 267 |
+
device = torch.device(device)
|
| 268 |
+
tensor = transform(rgb).unsqueeze(0).to(device)
|
| 269 |
+
targets = _xai_targets(model)
|
| 270 |
+
|
| 271 |
+
captured: dict[str, dict[str, torch.Tensor | str]] = {}
|
| 272 |
+
handles = []
|
| 273 |
+
|
| 274 |
+
def make_hook(name: str, kind: str):
|
| 275 |
+
def hook(_module, _inputs, output):
|
| 276 |
+
if isinstance(output, (tuple, list)):
|
| 277 |
+
output = output[0]
|
| 278 |
+
if not isinstance(output, torch.Tensor) or not output.requires_grad:
|
| 279 |
+
return
|
| 280 |
+
captured[name] = {"activation": output, "kind": kind}
|
| 281 |
+
output.register_hook(lambda grad, key=name: captured[key].__setitem__("gradient", grad))
|
| 282 |
+
|
| 283 |
+
return hook
|
| 284 |
+
|
| 285 |
+
for name, kind, module in targets:
|
| 286 |
+
handles.append(module.register_forward_hook(make_hook(name, kind)))
|
| 287 |
+
|
| 288 |
+
maps: dict[str, np.ndarray] = {}
|
| 289 |
+
logits = None
|
| 290 |
+
try:
|
| 291 |
+
model.zero_grad(set_to_none=True)
|
| 292 |
+
tensor = tensor.detach().clone().requires_grad_(True)
|
| 293 |
+
with torch.enable_grad():
|
| 294 |
+
output = model(tensor)
|
| 295 |
+
logits = output["logits"] if isinstance(output, dict) else output
|
| 296 |
+
target = logits[:, int(target_class)].sum()
|
| 297 |
+
target.backward()
|
| 298 |
+
finally:
|
| 299 |
+
for handle in handles:
|
| 300 |
+
handle.remove()
|
| 301 |
+
|
| 302 |
+
size = tuple(int(v) for v in tensor.shape[-2:])
|
| 303 |
+
for name, data in captured.items():
|
| 304 |
+
activation = data.get("activation")
|
| 305 |
+
gradient = data.get("gradient")
|
| 306 |
+
kind = data.get("kind")
|
| 307 |
+
if not isinstance(activation, torch.Tensor) or not isinstance(gradient, torch.Tensor):
|
| 308 |
+
continue
|
| 309 |
+
if kind == "tokens":
|
| 310 |
+
cam = _cam_from_tokens(activation, gradient, size)
|
| 311 |
+
elif kind == "conv":
|
| 312 |
+
cam = _cam_from_conv(activation, gradient, size)
|
| 313 |
+
else:
|
| 314 |
+
cam = None
|
| 315 |
+
if cam is not None:
|
| 316 |
+
maps[name] = cam
|
| 317 |
+
|
| 318 |
+
input_map = _input_gradient_map(tensor, size)
|
| 319 |
+
if input_map is not None:
|
| 320 |
+
maps["input_gradient"] = input_map
|
| 321 |
+
|
| 322 |
+
occlusion_map = _occlusion_sensitivity_map(model, tensor.detach(), size, target_class=target_class)
|
| 323 |
+
if occlusion_map is not None:
|
| 324 |
+
maps["occlusion_drop"] = occlusion_map
|
| 325 |
+
|
| 326 |
+
if not maps:
|
| 327 |
+
maps["image_highpass_residual"] = _fallback_residual_map(rgb, size)
|
| 328 |
+
|
| 329 |
+
weights = {
|
| 330 |
+
"occlusion_drop": 0.55,
|
| 331 |
+
"vit_patch_gradcam": 0.25,
|
| 332 |
+
"srm_residual_gradcam": 0.12,
|
| 333 |
+
"input_gradient": 0.08,
|
| 334 |
+
"image_highpass_residual": 0.30,
|
| 335 |
+
}
|
| 336 |
+
combined = np.zeros_like(next(iter(maps.values())), dtype=np.float32)
|
| 337 |
+
total_weight = 0.0
|
| 338 |
+
for name, cam in maps.items():
|
| 339 |
+
weight = weights.get(name, 0.15)
|
| 340 |
+
combined += cam * weight
|
| 341 |
+
total_weight += weight
|
| 342 |
+
combined = _normalize_map(combined / max(total_weight, 1e-6))
|
| 343 |
+
expanded = _object_expand(combined, expansion=expansion)
|
| 344 |
+
|
| 345 |
+
heatmap = _cam_to_heatmap(expanded, rgb.size)
|
| 346 |
+
overlay = _overlay_heatmap(rgb, heatmap)
|
| 347 |
+
probs = None
|
| 348 |
+
if isinstance(logits, torch.Tensor):
|
| 349 |
+
probs = torch.softmax(logits.detach().float(), dim=-1)[0].cpu().tolist()
|
| 350 |
+
meta = {
|
| 351 |
+
"method": "occlusion_vit_srm_inputgrad",
|
| 352 |
+
"target_class": "fake" if int(target_class) == 1 else "real",
|
| 353 |
+
"branches": list(maps.keys()),
|
| 354 |
+
"heatmap_policy": "occlusion-weighted expanded evidence map",
|
| 355 |
+
"fake_prob": None if probs is None or len(probs) < 2 else float(probs[1]),
|
| 356 |
+
"note_ko": "붉고 노란 부분은 해당 영역을 흐리게 가렸을 때 AI 판정 점수가 많이 떨어진 위치입니다.",
|
| 357 |
+
"limitation_ko": "이 히트맵은 모델 판단 근거이지 실제 합성/수정 픽셀 마스크는 아닙니다. 사람이나 고양이 전체를 정확히 칠하려면 별도 객체 분할 또는 위치 라벨 학습이 필요합니다.",
|
| 358 |
+
}
|
| 359 |
+
return ExplainResult(heatmap=heatmap, overlay=overlay, meta=meta, raw_map=expanded)
|