wonjun12 commited on
Commit
e2ca968
·
verified ·
1 Parent(s): 8f3951f

Deploy A-EYE remote full ensemble backend

Browse files
.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
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.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
- MODEL_PATH=/app/models/best.pt
7
-
8
- WORKDIR /app
9
-
10
- # grad-cam(opencv) 실행에 필요한 시스템 라이브러리
11
- RUN apt-get update \
12
- && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
13
- && rm -rf /var/lib/apt/lists/*
14
-
15
- # CPU 전용 torch/torchvision 을 먼저 설치해 이미지 용량을 줄입니다.
16
- RUN pip install --upgrade pip \
17
- && pip install torch==2.5.1 torchvision==0.20.1 \
18
- --index-url https://download.pytorch.org/whl/cpu
19
-
20
- COPY requirements.txt .
21
- RUN pip install -r requirements.txt
22
-
23
- COPY . .
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
- fastapi==0.115.6
2
- uvicorn[standard]==0.34.0
3
- pydantic==2.10.4
4
- python-multipart==0.0.20
5
- pillow==11.0.0
6
- numpy==2.1.3
7
- torch==2.5.1
8
- torchvision==0.20.1
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)