wonjun12 commited on
Commit
5fd05c3
·
verified ·
1 Parent(s): b0b6f6e

Deploy A-EYE remote full ensemble backend

Browse files
.dockerignore CHANGED
@@ -3,9 +3,14 @@ __pycache__/
3
  *.pyc
4
  .pytest_cache/
5
  .env
6
- models/*.pt
7
- models/*.pth
8
- models/*.onnx
9
- models/*.safetensors
10
  *.log
11
  .DS_Store
 
 
 
 
 
 
 
 
 
 
3
  *.pyc
4
  .pytest_cache/
5
  .env
 
 
 
 
6
  *.log
7
  .DS_Store
8
+ experiments/
9
+ dataset/
10
+ dataset_20260509/
11
+ hard_examples/
12
+ logs/
13
+ _hf_space_ensemble/
14
+ _hf_space_remote_full/
15
+ _hf_space_slim_32_37/
16
+ _hf_model_repo_full_ensemble/
.env.example DELETED
@@ -1,4 +0,0 @@
1
- MODEL_PATH=./models/best.pt
2
- INPUT_SIZE=224
3
- IMAGE_MEAN=0.485,0.456,0.406
4
- IMAGE_STD=0.229,0.224,0.225
 
 
 
 
 
.gitignore DELETED
@@ -1,11 +0,0 @@
1
- venv/
2
- __pycache__/
3
- *.pyc
4
- .pytest_cache/
5
- .env
6
- models/*.pt
7
- models/*.pth
8
- models/*.onnx
9
- models/*.safetensors
10
- *.log
11
- .DS_Store
 
 
 
 
 
 
 
 
 
 
 
 
DEPLOY.md DELETED
@@ -1,95 +0,0 @@
1
- # A-EYE 백엔드 무료 배포 가이드 (Hugging Face Spaces)
2
-
3
- 앱이 **앱스토어 심사를 통과하려면**, 심사관이 어디서든 접속할 수 있는
4
- **공개 HTTPS 백엔드**가 있어야 합니다. (집 와이파이 IP나 ngrok 임시 주소로는 통과 못 합니다.)
5
-
6
- 이 모델은 **737MB + torch** 라서 RAM 2GB 이상이 필요한데,
7
- **Hugging Face Spaces** 가 RAM 16GB를 무료로 제공해서 유일하게 맞는 무료 옵션입니다.
8
- (Render 무료 512MB는 메모리 부족으로 실패합니다.)
9
-
10
- ---
11
-
12
- ## 1단계 — 모델 파일 업로드 (브라우저, 터미널 X)
13
-
14
- 1. https://huggingface.co 가입 (구글 계정으로 30초).
15
- 2. 우측 상단 프로필 → **New Model** → 이름 예: `aeye-model` → Create.
16
- 3. 모델 페이지 → **Files and versions** 탭 → **Add file → Upload files** →
17
- `best.pt` 드래그&드롭 → Commit. (대용량 LFS 자동 처리)
18
- 4. 업로드된 `best.pt` 옆 **⋯ → Copy download link** 로 직링크 복사. 형식:
19
- ```
20
- https://huggingface.co/<유저명>/aeye-model/resolve/main/best.pt
21
- ```
22
- 이게 아래에서 쓸 **MODEL_URL** 입니다.
23
-
24
- ---
25
-
26
- ## 2단계 — Space(서버) 만들기 (브라우저)
27
-
28
- 1. 프로필 → **New Space**.
29
- 2. 설정:
30
- - **Owner / Space name**: 예 `aeye-backend`
31
- - **SDK**: **Docker** 선택 (중요)
32
- - **Hardware**: `CPU basic` (무료, 16GB RAM)
33
- - 공개/비공개: **Public** 권장 (앱이 호출해야 함)
34
- 3. **Create Space** → 빈 Space가 생깁니다.
35
-
36
- ---
37
-
38
- ## 3단계 — 백엔드 코드 올리기 (터미널, 명령 몇 줄)
39
-
40
- Space는 git 저장소입니다. `a-eye/backend` 폴더 내용을 Space에 넣습니다.
41
-
42
- ```bash
43
- # 작업 폴더에서
44
- git clone https://huggingface.co/spaces/<유저명>/aeye-backend
45
- cp -R "/Users/jun/Desktop/a-eye Design System/a-eye/backend/." aeye-backend/
46
- cd aeye-backend
47
- git add .
48
- git commit -m "deploy A-EYE backend"
49
- git push
50
- ```
51
-
52
- > push 할 때 사용자명은 HF 아이디, 비밀번호는 **HF 액세스 토큰**입니다.
53
- > 토큰: https://huggingface.co/settings/tokens → New token(write) 생성 후 비밀번호 자리에 붙여넣기.
54
- > (`venv/`, 모델 파일은 `.gitignore`로 자동 제외됩니다.)
55
-
56
- ---
57
-
58
- ## 4단계 — 모델 주소 알려주기 (브라우저)
59
-
60
- 1. Space 페이지 → **Settings → Variables and secrets** → **New secret**.
61
- 2. 추가:
62
- | Name | Value |
63
- |------|-------|
64
- | `MODEL_URL` | 1단계에서 복사한 `best.pt` 직링크 |
65
-
66
- 3. Space가 자동으로 다시 빌드됩니다. 첫 빌드는 torch 설치 + 모델 다운로드로 **5~10분** 걸립니다.
67
-
68
- ### 동작 확인
69
- Space 주소는 `https://<유저명>-aeye-backend.hf.space` 형태입니다.
70
- ```bash
71
- curl https://<유저명>-aeye-backend.hf.space/api/health # {"status":"ok"}
72
- curl https://<유저명>-aeye-backend.hf.space/api/version # model_version 표시(mock 아니면 정상)
73
- ```
74
-
75
- ---
76
-
77
- ## 5단계 — 앱에 백엔드 주소 연결
78
-
79
- `a-eye/app.json` 의 `extra.defaultBackendUrl` 수정:
80
- ```json
81
- "extra": {
82
- "defaultBackendUrl": "https://<유저명>-aeye-backend.hf.space"
83
- }
84
- ```
85
- 이제 앱은 설치 즉시 분석이 동작합니다. (사용자는 설정에서 다른 서버로 바꿀 수도 있음)
86
-
87
- ---
88
-
89
- ## 참고
90
- - 무료 Space는 일정 시간 미사용 시 잠들고(sleep), 다음 첫 요청 때 깨어나며 30초~1분 걸릴 수 있습니다.
91
- 앱스토어 심사 직전에 위 `curl /api/health` 로 한 번 깨워두면 좋습니다.
92
- - 트래픽이 늘면 Space Settings에서 유료 하드웨어로 업그레이드할 수 있습니다.
93
-
94
- > Render/Railway/Fly.io 도 같은 `Dockerfile` + `MODEL_URL` 환경변수로 배포할 수 있습니다.
95
- > 단, 모델 로딩에 RAM 2GB 이상 플랜이 필요하며 대부분 유료입니다.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -16,6 +16,7 @@ RUN apt-get update \
16
 
17
  COPY requirements.txt .
18
  RUN pip install --upgrade pip \
 
19
  && pip install -r requirements.txt
20
 
21
  COPY . .
 
16
 
17
  COPY requirements.txt .
18
  RUN pip install --upgrade pip \
19
+ && pip install torch==2.5.1 torchvision==0.20.1 --index-url https://download.pytorch.org/whl/cpu \
20
  && pip install -r requirements.txt
21
 
22
  COPY . .
README.md CHANGED
@@ -1,54 +1,23 @@
1
  ---
2
  title: A-EYE Backend
3
- emoji: 🧠
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: docker
7
  app_port: 8000
8
  pinned: false
9
  ---
10
 
11
- # A-EYE 백엔드
12
 
13
- FastAPI 기반 이미지 판별 서버입니다. `MODEL_PATH` 파일이 없거나 비어 있으면 MOCK 모드로 실행되어 앱 end-to-end 테스트가 가능합니다.
14
 
15
- ## 실행
16
 
17
- ```bash
18
- python -m venv venv && source venv/bin/activate
19
- pip install -r requirements.txt
20
- cp .env.example .env
21
- uvicorn main:app --host 0.0.0.0 --port 8000 --reload
22
  ```
23
 
24
- ## 팀원 모델 파일 적용
25
 
26
- 구글드라이브에서 받은 `best22.pt`는 GitHub에 올리지 않습니다. 각자 내려받은 뒤 파일 이름을 `best.pt`로 바꿔서 아래 위치에 넣어 주세요.
27
-
28
- ```bash
29
- mkdir -p models
30
- cp ~/Downloads/best22.pt models/best.pt
31
- echo "24_realworld_artifact_30ep" > models/best.pt.version.txt
32
- ```
33
-
34
- `.env`의 `MODEL_PATH`는 기본값 그대로 두면 됩니다.
35
-
36
- ```bash
37
- MODEL_PATH=./models/best.pt
38
  ```
39
-
40
- ## 모델 교체
41
-
42
- 1. `models/best.pt`를 실제 모델 파일로 교체합니다.
43
- 2. 선택 사항으로 `models/best.pt.version.txt`의 버전을 갱신합니다.
44
- 3. `uvicorn`을 재시작합니다.
45
-
46
- 코드 수정은 필요 없습니다.
47
-
48
- ## GPU 사용
49
-
50
- 기본은 CPU 로딩입니다. GPU 서버에서는 `model_loader.py`의 `map_location`을 환경에 맞춰 조정하거나 배포 이미지에서 CUDA PyTorch를 설치하세요.
51
-
52
- ## CORS
53
-
54
- 개발 편의를 위해 모든 origin을 허용합니다. 운영 환경에서는 `main.py`의 `allow_origins`를 앱 도메인 또는 배포된 클라이언트 주소로 좁히세요.
 
1
  ---
2
  title: A-EYE Backend
 
 
 
3
  sdk: docker
4
  app_port: 8000
5
  pinned: false
6
  ---
7
 
8
+ # A-EYE Backend
9
 
10
+ Production ensemble API for A-EYE.
11
 
12
+ Health:
13
 
14
+ ```text
15
+ GET /api/health
 
 
 
16
  ```
17
 
18
+ Prediction:
19
 
20
+ ```text
21
+ POST /api/predict
22
+ multipart form field: image
 
 
 
 
 
 
 
 
 
23
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
artifact_model.py DELETED
@@ -1,273 +0,0 @@
1
- from collections import OrderedDict
2
- from typing import Any
3
-
4
- import torch
5
- from torch import nn
6
- from torchvision.models import vit_b_16
7
-
8
-
9
- class LoRALinear(nn.Module):
10
- def __init__(self, in_features: int, out_features: int, r: int = 32, alpha: int = 64, bias: bool = True) -> None:
11
- super().__init__()
12
- self.base = nn.Linear(in_features, out_features, bias=bias)
13
- self.lora_A = nn.Parameter(torch.zeros(r, in_features))
14
- self.lora_B = nn.Parameter(torch.zeros(out_features, r))
15
- self.scale = alpha / r
16
-
17
- def forward(self, x: torch.Tensor) -> torch.Tensor:
18
- return self.base(x) + torch.nn.functional.linear(
19
- torch.nn.functional.linear(x, self.lora_A),
20
- self.lora_B,
21
- ) * self.scale
22
-
23
-
24
- class ViTEncoder(nn.Module):
25
- def __init__(self, dropout: float = 0.32) -> None:
26
- super().__init__()
27
- self.backbone = vit_b_16(weights=None)
28
- self.backbone.heads = nn.Identity()
29
- for layer in self.backbone.encoder.layers:
30
- layer.mlp[0] = LoRALinear(768, 3072)
31
- layer.mlp[3] = LoRALinear(3072, 768)
32
- self.head = nn.Sequential(
33
- nn.BatchNorm1d(768),
34
- nn.Dropout(dropout),
35
- nn.Linear(768, 2),
36
- )
37
-
38
- def features(self, x: torch.Tensor) -> torch.Tensor:
39
- return self.backbone(x)
40
-
41
- def forward(self, x: torch.Tensor) -> torch.Tensor:
42
- return self.head(self.features(x))
43
-
44
-
45
- class CLIPSelfAttention(nn.Module):
46
- def __init__(self) -> None:
47
- super().__init__()
48
- self.k_proj = nn.Linear(768, 768)
49
- self.v_proj = LoRALinear(768, 768)
50
- self.q_proj = LoRALinear(768, 768)
51
- self.out_proj = nn.Linear(768, 768)
52
- self.num_heads = 12
53
- self.head_dim = 64
54
- self.scale = self.head_dim ** -0.5
55
-
56
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
57
- batch, tokens, _ = hidden_states.shape
58
- q = self.q_proj(hidden_states).view(batch, tokens, self.num_heads, self.head_dim).transpose(1, 2)
59
- k = self.k_proj(hidden_states).view(batch, tokens, self.num_heads, self.head_dim).transpose(1, 2)
60
- v = self.v_proj(hidden_states).view(batch, tokens, self.num_heads, self.head_dim).transpose(1, 2)
61
- attn = torch.softmax((q @ k.transpose(-2, -1)) * self.scale, dim=-1)
62
- out = (attn @ v).transpose(1, 2).reshape(batch, tokens, 768)
63
- return self.out_proj(out)
64
-
65
-
66
- class CLIPEncoderLayer(nn.Module):
67
- def __init__(self) -> None:
68
- super().__init__()
69
- self.self_attn = CLIPSelfAttention()
70
- self.layer_norm1 = nn.LayerNorm(768)
71
- self.mlp = nn.Module()
72
- self.mlp.fc1 = nn.Linear(768, 3072)
73
- self.mlp.fc2 = nn.Linear(3072, 768)
74
- self.layer_norm2 = nn.LayerNorm(768)
75
- self.activation = nn.GELU()
76
-
77
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
78
- hidden_states = hidden_states + self.self_attn(self.layer_norm1(hidden_states))
79
- mlp_out = self.mlp.fc2(self.activation(self.mlp.fc1(self.layer_norm2(hidden_states))))
80
- return hidden_states + mlp_out
81
-
82
-
83
- class CLIPVisionInner(nn.Module):
84
- def __init__(self) -> None:
85
- super().__init__()
86
- self.embeddings = nn.Module()
87
- self.embeddings.class_embedding = nn.Parameter(torch.zeros(768))
88
- self.embeddings.patch_embedding = nn.Conv2d(3, 768, kernel_size=32, stride=32, bias=False)
89
- self.embeddings.position_embedding = nn.Embedding(50, 768)
90
- self.pre_layrnorm = nn.LayerNorm(768)
91
- self.encoder = nn.Module()
92
- self.encoder.layers = nn.ModuleList([CLIPEncoderLayer() for _ in range(12)])
93
- self.post_layernorm = nn.LayerNorm(768)
94
-
95
- def forward(self, x: torch.Tensor) -> torch.Tensor:
96
- patch = self.embeddings.patch_embedding(x).flatten(2).transpose(1, 2)
97
- cls = self.embeddings.class_embedding.to(dtype=patch.dtype).expand(x.shape[0], 1, -1)
98
- hidden = torch.cat([cls, patch], dim=1)
99
- positions = torch.arange(hidden.shape[1], device=x.device).unsqueeze(0)
100
- hidden = hidden + self.embeddings.position_embedding(positions)
101
- hidden = self.pre_layrnorm(hidden)
102
- for layer in self.encoder.layers:
103
- hidden = layer(hidden)
104
- return self.post_layernorm(hidden[:, 0])
105
-
106
-
107
- class CLIPVisionWithProjection(nn.Module):
108
- def __init__(self) -> None:
109
- super().__init__()
110
- self.vision_model = CLIPVisionInner()
111
- self.visual_projection = nn.Linear(768, 512, bias=False)
112
-
113
- def forward(self, x: torch.Tensor) -> torch.Tensor:
114
- return self.visual_projection(self.vision_model(x))
115
-
116
-
117
- class CLIPEncoder(nn.Module):
118
- def __init__(self, dropout: float = 0.32) -> None:
119
- super().__init__()
120
- self.vision_model = CLIPVisionWithProjection()
121
- self.head = nn.Sequential(
122
- nn.BatchNorm1d(512),
123
- nn.Dropout(dropout),
124
- nn.Linear(512, 2),
125
- )
126
-
127
- def features(self, x: torch.Tensor) -> torch.Tensor:
128
- return self.vision_model(x)
129
-
130
- def forward(self, x: torch.Tensor) -> torch.Tensor:
131
- return self.head(self.features(x))
132
-
133
-
134
- class FFTBranch(nn.Module):
135
- def __init__(self, dropout: float = 0.10) -> None:
136
- super().__init__()
137
- self.conv = nn.Sequential(OrderedDict([
138
- ("0", nn.Conv2d(3, 32, 7, padding=3, bias=False)),
139
- ("1", nn.BatchNorm2d(32)),
140
- ("2", nn.ReLU(inplace=True)),
141
- ("3", nn.Conv2d(32, 64, 5, padding=2, bias=False)),
142
- ("4", nn.BatchNorm2d(64)),
143
- ("5", nn.ReLU(inplace=True)),
144
- ("6", nn.Conv2d(64, 128, 3, padding=1, bias=False)),
145
- ("7", nn.BatchNorm2d(128)),
146
- ("8", nn.ReLU(inplace=True)),
147
- ("9", nn.Conv2d(128, 128, 3, padding=1, bias=False)),
148
- ("10", nn.BatchNorm2d(128)),
149
- ("11", nn.ReLU(inplace=True)),
150
- ]))
151
- self.pool = nn.AdaptiveAvgPool2d((1, 1))
152
- self.proj = nn.Sequential(
153
- nn.Dropout(dropout),
154
- nn.Linear(128, 256),
155
- nn.LayerNorm(256),
156
- )
157
-
158
- def forward(self, x: torch.Tensor) -> torch.Tensor:
159
- fft = torch.fft.fft2(x, norm="ortho")
160
- mag = torch.log1p(torch.abs(torch.fft.fftshift(fft, dim=(-2, -1))))
161
- feat = self.pool(self.conv(mag)).flatten(1)
162
- return self.proj(feat)
163
-
164
-
165
- class SRMBranch(nn.Module):
166
- def __init__(self, dropout: float = 0.10) -> None:
167
- super().__init__()
168
- self.register_buffer("clip_mean", torch.zeros(1, 3, 1, 1))
169
- self.register_buffer("clip_std", torch.ones(1, 3, 1, 1))
170
- self.highpass = nn.Conv2d(3, 18, 5, padding=2, bias=False)
171
- self.encoder = nn.Sequential(OrderedDict([
172
- ("0", nn.Conv2d(18, 48, 3, padding=1, bias=False)),
173
- ("1", nn.BatchNorm2d(48)),
174
- ("2", nn.ReLU(inplace=True)),
175
- ("3", nn.Conv2d(48, 96, 3, padding=1, bias=False)),
176
- ("4", nn.BatchNorm2d(96)),
177
- ("5", nn.ReLU(inplace=True)),
178
- ("6", nn.Conv2d(96, 128, 3, padding=1, bias=False)),
179
- ("7", nn.BatchNorm2d(128)),
180
- ("8", nn.ReLU(inplace=True)),
181
- ("9", nn.Conv2d(128, 128, 3, padding=1, bias=False)),
182
- ("10", nn.BatchNorm2d(128)),
183
- ("11", nn.ReLU(inplace=True)),
184
- ]))
185
- self.pool = nn.AdaptiveAvgPool2d((1, 1))
186
- self.proj = nn.Sequential(
187
- nn.Dropout(dropout),
188
- nn.Linear(128, 128),
189
- nn.LayerNorm(128),
190
- )
191
-
192
- def forward(self, x: torch.Tensor) -> torch.Tensor:
193
- normalized = (x - self.clip_mean) / self.clip_std.clamp_min(1e-6)
194
- residual = self.highpass(normalized)
195
- feat = self.pool(self.encoder(residual)).flatten(1)
196
- return self.proj(feat)
197
-
198
-
199
- class ArtifactLoRAHybrid(nn.Module):
200
- expects_raw_input = True
201
-
202
- def __init__(self, dropout: float = 0.32) -> None:
203
- super().__init__()
204
- self.register_buffer("clip_mean", torch.zeros(1, 3, 1, 1))
205
- self.register_buffer("clip_std", torch.ones(1, 3, 1, 1))
206
- self.vit_encoder = ViTEncoder(dropout=dropout)
207
- self.clip_encoder = CLIPEncoder(dropout=dropout)
208
- self.fft_branch = FFTBranch()
209
- self.srm_branch = SRMBranch()
210
- self.head = nn.Sequential(
211
- nn.BatchNorm1d(1664),
212
- nn.Linear(1664, 832),
213
- nn.ReLU(inplace=True),
214
- nn.Dropout(dropout),
215
- nn.Linear(832, 416),
216
- nn.ReLU(inplace=True),
217
- nn.Dropout(dropout),
218
- nn.Linear(416, 2),
219
- )
220
- self.register_buffer("imagenet_mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1), persistent=False)
221
- self.register_buffer("imagenet_std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1), persistent=False)
222
-
223
- def _branch_features(self, x: torch.Tensor):
224
- raw = x.clamp(0, 1)
225
- vit_x = (raw - self.imagenet_mean) / self.imagenet_std
226
- clip_x = (raw - self.clip_mean) / self.clip_std.clamp_min(1e-6)
227
- vit_feat = self.vit_encoder.features(vit_x)
228
- clip_feat = self.clip_encoder.features(clip_x)
229
- fft_feat = self.fft_branch(raw)
230
- srm_feat = self.srm_branch(raw)
231
- return vit_feat, clip_feat, fft_feat, srm_feat
232
-
233
- def forward(self, x: torch.Tensor) -> torch.Tensor:
234
- vit_feat, clip_feat, fft_feat, srm_feat = self._branch_features(x)
235
- return self.head(torch.cat([vit_feat, clip_feat, fft_feat, srm_feat], dim=1))
236
-
237
- def forward_breakdown(self, x: torch.Tensor):
238
- """최종 로짓과 함께 각 관점별(구조/맥락/디테일) 실제 로짓을 반환합니다.
239
-
240
- - structure: ViT 인코더의 자체 분류 헤드 (전역 구조/형태)
241
- - context: CLIP 인코더의 자체 분류 헤드 (의미적 맥락)
242
- - detail: 주파수(FFT)+노이즈(SRM) 브랜치만 통과시킨 메인 헤드 (질감/흔적)
243
-
244
- 모두 학습된 가중치를 사용하는 실제 추론 결과이며, 임의 값이 아닙니다.
245
- """
246
- vit_feat, clip_feat, fft_feat, srm_feat = self._branch_features(x)
247
- final_logits = self.head(torch.cat([vit_feat, clip_feat, fft_feat, srm_feat], dim=1))
248
-
249
- structure_logits = self.vit_encoder.head(vit_feat)
250
- context_logits = self.clip_encoder.head(clip_feat)
251
- # 디테일 관점: 구조/맥락 특징을 0으로 두고 주파수·노이즈 특징만으로 판단
252
- detail_logits = self.head(
253
- torch.cat([torch.zeros_like(vit_feat), torch.zeros_like(clip_feat), fft_feat, srm_feat], dim=1)
254
- )
255
- return final_logits, {
256
- "structure": structure_logits,
257
- "context": context_logits,
258
- "detail": detail_logits,
259
- }
260
-
261
-
262
- def build_artifact_lora_hybrid() -> ArtifactLoRAHybrid:
263
- return ArtifactLoRAHybrid()
264
-
265
-
266
- def is_artifact_lora_state_dict(state_dict: dict[str, Any]) -> bool:
267
- return (
268
- "vit_encoder.backbone.class_token" in state_dict
269
- and "clip_encoder.vision_model.visual_projection.weight" in state_dict
270
- and "fft_branch.proj.1.weight" in state_dict
271
- and "srm_branch.highpass.weight" in state_dict
272
- and "head.7.weight" in state_dict
273
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inference.py DELETED
@@ -1,134 +0,0 @@
1
- import hashlib
2
- import io
3
- import random
4
- import time
5
- from typing import Any
6
-
7
- import numpy as np
8
- import torch
9
- from PIL import Image, UnidentifiedImageError
10
-
11
- from model_loader import LoadedModel, read_input_size, read_norm
12
- from xai import deterministic_heatmap, gradcam_or_fallback, overlay_heatmap, png_b64
13
-
14
-
15
- class InvalidImageError(ValueError):
16
- pass
17
-
18
-
19
- def run_inference(image_bytes: bytes, loaded: LoadedModel) -> dict[str, Any]:
20
- started = time.perf_counter()
21
- original = _decode_image(image_bytes)
22
-
23
- if loaded.is_mock:
24
- score = _mock_score(image_bytes)
25
- heatmap = deterministic_heatmap(score)
26
- overlay = overlay_heatmap(original, heatmap)
27
- return {
28
- "score": score,
29
- "verdict": "AI" if score >= 0.5 else "REAL",
30
- "heatmap_b64": png_b64(heatmap),
31
- "overlay_b64": png_b64(overlay),
32
- "model_version": loaded.model_version,
33
- "elapsed_ms": _elapsed_ms(started),
34
- }
35
-
36
- if loaded.load_error:
37
- raise RuntimeError(f"Model not loaded: {loaded.load_error}")
38
-
39
- input_tensor = _preprocess(original)
40
- score, breakdown = _score_with_breakdown(loaded.model, input_tensor, image_bytes)
41
- heatmap_b64, overlay_b64 = gradcam_or_fallback(loaded.model, input_tensor, original, score)
42
- result = {
43
- "score": score,
44
- "verdict": "AI" if score >= 0.5 else "REAL",
45
- "heatmap_b64": heatmap_b64,
46
- "overlay_b64": overlay_b64,
47
- "model_version": loaded.model_version,
48
- "elapsed_ms": _elapsed_ms(started),
49
- }
50
- if breakdown is not None:
51
- result["structure_score"] = breakdown["structure"]
52
- result["context_score"] = breakdown["context"]
53
- result["detail_score"] = breakdown["detail"]
54
- return result
55
-
56
-
57
- def _decode_image(image_bytes: bytes) -> Image.Image:
58
- try:
59
- return Image.open(io.BytesIO(image_bytes)).convert("RGB")
60
- except (UnidentifiedImageError, OSError) as exc:
61
- raise InvalidImageError("Invalid image") from exc
62
-
63
-
64
- def _preprocess(image: Image.Image) -> torch.Tensor:
65
- input_size = read_input_size()
66
- resized = image.resize((input_size, input_size))
67
- array = np.asarray(resized, dtype=np.float32) / 255.0
68
- tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
69
- return tensor
70
-
71
-
72
- def _preprocess_imagenet(image: Image.Image) -> torch.Tensor:
73
- input_size = read_input_size()
74
- mean, std = read_norm()
75
- resized = image.resize((input_size, input_size))
76
- array = np.asarray(resized, dtype=np.float32) / 255.0
77
- array = (array - np.array(mean, dtype=np.float32)) / np.array(std, dtype=np.float32)
78
- tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
79
- return tensor
80
-
81
-
82
- def _logits_to_score(output: torch.Tensor) -> float:
83
- flat = output.detach().float().reshape(output.shape[0], -1)
84
- if flat.shape[1] == 1:
85
- score = torch.sigmoid(flat[0, 0]).item()
86
- else:
87
- score = torch.softmax(flat[0, :2], dim=0)[1].item()
88
- return float(max(0.0, min(1.0, score)))
89
-
90
-
91
- def _score_with_breakdown(
92
- model: Any, input_tensor: torch.Tensor, image_bytes: bytes
93
- ) -> tuple[float, dict[str, float] | None]:
94
- """최종 점수와 관점별 실제 세부 점수(가능하면)를 함께 계산합니다."""
95
- if hasattr(model, "forward_breakdown"):
96
- try:
97
- with torch.no_grad():
98
- final_logits, parts = model.forward_breakdown(input_tensor)
99
- score = _logits_to_score(final_logits)
100
- breakdown = {key: _logits_to_score(value) for key, value in parts.items()}
101
- return score, breakdown
102
- except Exception: # noqa: BLE001 - 세부 점수 실패가 전체 분석을 막지 않도록 함
103
- pass
104
- return _score_model(model, input_tensor, image_bytes), None
105
-
106
-
107
- def _score_model(model: Any, input_tensor: torch.Tensor, image_bytes: bytes) -> float:
108
- if not hasattr(model, "__call__"):
109
- return _mock_score(image_bytes)
110
- if not getattr(model, "expects_raw_input", False):
111
- input_tensor = _preprocess_imagenet(Image.open(io.BytesIO(image_bytes)).convert("RGB"))
112
- with torch.no_grad():
113
- output = model(input_tensor)
114
- if isinstance(output, (tuple, list)):
115
- output = output[0]
116
- if not isinstance(output, torch.Tensor):
117
- return _mock_score(image_bytes)
118
- flat = output.detach().float().reshape(output.shape[0], -1)
119
- if flat.shape[1] == 1:
120
- score = torch.sigmoid(flat[0, 0]).item()
121
- else:
122
- score = torch.softmax(flat[0, :2], dim=0)[1].item()
123
- return float(max(0.0, min(1.0, score)))
124
-
125
-
126
- def _mock_score(image_bytes: bytes) -> float:
127
- digest = hashlib.sha256(image_bytes).digest()
128
- seed = int.from_bytes(digest[:8], "big")
129
- random.seed(seed)
130
- return random.random()
131
-
132
-
133
- def _elapsed_ms(started: float) -> int:
134
- return int((time.perf_counter() - started) * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py DELETED
@@ -1,67 +0,0 @@
1
- from contextlib import asynccontextmanager
2
- from typing import AsyncIterator
3
-
4
- from fastapi import FastAPI, File, HTTPException, UploadFile
5
- from fastapi.middleware.cors import CORSMiddleware
6
-
7
- from inference import InvalidImageError, run_inference
8
- from model_loader import LoadedModel, ensure_model_file, load_model, read_model_path
9
- from schemas import AnalyzeResponse, HealthResponse, VersionResponse
10
-
11
- MAX_IMAGE_BYTES = 10 * 1024 * 1024
12
-
13
-
14
- @asynccontextmanager
15
- async def lifespan(app: FastAPI) -> AsyncIterator[None]:
16
- model_path = read_model_path()
17
- ensure_model_file(model_path)
18
- app.state.loaded_model = load_model(model_path)
19
- yield
20
-
21
-
22
- app = FastAPI(title="A-EYE Inference API", version="1.0.0", lifespan=lifespan)
23
-
24
- # 모바일 앱은 쿠키/세션 자격증명을 사용하지 않으므로 credentials 를 비활성화합니다.
25
- # (allow_origins=["*"] 와 allow_credentials=True 조합은 브라우저에서 거부되며 보안상 위험합니다.)
26
- app.add_middleware(
27
- CORSMiddleware,
28
- allow_origins=["*"],
29
- allow_credentials=False,
30
- allow_methods=["GET", "POST", "OPTIONS"],
31
- allow_headers=["*"],
32
- )
33
-
34
-
35
- @app.get("/api/health", response_model=HealthResponse)
36
- async def health() -> HealthResponse:
37
- return HealthResponse(status="ok")
38
-
39
-
40
- @app.get("/api/version", response_model=VersionResponse)
41
- async def version() -> VersionResponse:
42
- loaded = _loaded_model()
43
- return VersionResponse(model_version=loaded.model_version, model_path=loaded.model_path)
44
-
45
-
46
- @app.post("/api/analyze", response_model=AnalyzeResponse)
47
- async def analyze(image: UploadFile = File(...)) -> AnalyzeResponse:
48
- content_type = image.content_type or ""
49
- if content_type not in {"image/jpeg", "image/png", "image/webp"}:
50
- raise HTTPException(status_code=400, detail="Invalid image")
51
- image_bytes = await image.read()
52
- if not image_bytes or len(image_bytes) > MAX_IMAGE_BYTES:
53
- raise HTTPException(status_code=400, detail="Invalid image")
54
- try:
55
- result = run_inference(image_bytes, _loaded_model())
56
- return AnalyzeResponse(**result)
57
- except InvalidImageError:
58
- raise HTTPException(status_code=400, detail="Invalid image") from None
59
- except RuntimeError as exc:
60
- message = str(exc)
61
- if message.startswith("Model not loaded"):
62
- raise HTTPException(status_code=503, detail=message) from exc
63
- raise
64
-
65
-
66
- def _loaded_model() -> LoadedModel:
67
- return app.state.loaded_model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
model_loader.py DELETED
@@ -1,117 +0,0 @@
1
- import logging
2
- import os
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import Any, Optional
6
-
7
- import torch
8
- from dotenv import load_dotenv
9
-
10
- from artifact_model import build_artifact_lora_hybrid, is_artifact_lora_state_dict
11
-
12
- logger = logging.getLogger("aeye.model")
13
- load_dotenv()
14
-
15
-
16
- @dataclass(frozen=True)
17
- class LoadedModel:
18
- model: Any
19
- model_path: str
20
- model_version: str
21
- is_mock: bool
22
- load_error: Optional[str] = None
23
-
24
-
25
- def read_model_path() -> str:
26
- return os.getenv("MODEL_PATH", "./models/best.pt")
27
-
28
-
29
- def ensure_model_file(model_path: Optional[str] = None) -> None:
30
- """모델 파일이 없고 MODEL_URL 이 설정돼 있으면 시작 시 한 번 내려받습니다.
31
-
32
- 737MB 모델 가중치는 git 저장소에 올리지 않으므로, 클라우드 배포 시
33
- Hugging Face Hub / S3 / 직접 링크 등의 MODEL_URL 에서 받아오도록 합니다.
34
- """
35
- import urllib.request
36
-
37
- path = Path(model_path or read_model_path())
38
- if path.exists() and path.stat().st_size > 0:
39
- return
40
- url = os.getenv("MODEL_URL", "").strip()
41
- if not url:
42
- logger.warning("MODEL_URL 이 없고 %s 도 없습니다. MOCK 모드로 동작합니다.", path)
43
- return
44
- path.parent.mkdir(parents=True, exist_ok=True)
45
- logger.info("모델 다운로드 시작: %s -> %s", url, path)
46
- tmp = path.with_suffix(path.suffix + ".part")
47
- try:
48
- urllib.request.urlretrieve(url, tmp) # noqa: S310 - 운영자가 지정한 신뢰된 URL
49
- tmp.replace(path)
50
- logger.info("모델 다운로드 완료: %s (%d bytes)", path, path.stat().st_size)
51
- except Exception:
52
- logger.exception("모델 다운로드 실패")
53
- if tmp.exists():
54
- tmp.unlink(missing_ok=True)
55
-
56
-
57
- def read_input_size() -> int:
58
- raw = os.getenv("INPUT_SIZE", "224")
59
- try:
60
- return max(32, int(raw))
61
- except ValueError:
62
- return 224
63
-
64
-
65
- def read_norm() -> tuple[list[float], list[float]]:
66
- mean = _read_float_list("IMAGE_MEAN", [0.485, 0.456, 0.406])
67
- std = _read_float_list("IMAGE_STD", [0.229, 0.224, 0.225])
68
- return mean, std
69
-
70
-
71
- def load_model(model_path: Optional[str] = None) -> LoadedModel:
72
- path = Path(model_path or read_model_path())
73
- if not path.exists() or path.stat().st_size == 0:
74
- logger.warning("MOCK MODE: %s not found, using random predictions", path)
75
- return LoadedModel(model=None, model_path="MOCK", model_version="mock-0.0.0", is_mock=True)
76
-
77
- version = _read_version(path)
78
- try:
79
- checkpoint = torch.load(path, map_location="cpu")
80
- if isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
81
- state_dict = checkpoint["model_state_dict"]
82
- if isinstance(state_dict, dict) and is_artifact_lora_state_dict(state_dict):
83
- model = build_artifact_lora_hybrid()
84
- missing, unexpected = model.load_state_dict(state_dict, strict=False)
85
- if missing or unexpected:
86
- logger.warning("Artifact model loaded with missing=%s unexpected=%s", missing, unexpected)
87
- else:
88
- raise RuntimeError("Unsupported checkpoint state_dict architecture")
89
- else:
90
- model = checkpoint
91
- if hasattr(model, "eval"):
92
- model.eval()
93
- logger.info("Loaded model from %s version %s", path, version)
94
- return LoadedModel(model=model, model_path=str(path), model_version=version, is_mock=False)
95
- except Exception as exc:
96
- reason = f"{type(exc).__name__}: {exc}"
97
- logger.exception("Model load failed")
98
- return LoadedModel(model=None, model_path=str(path), model_version=version, is_mock=False, load_error=reason)
99
-
100
-
101
- def _read_version(path: Path) -> str:
102
- sidecar = Path(f"{path}.version.txt")
103
- if not sidecar.exists():
104
- return "unknown"
105
- value = sidecar.read_text(encoding="utf-8").strip()
106
- return value or "unknown"
107
-
108
-
109
- def _read_float_list(key: str, fallback: list[float]) -> list[float]:
110
- raw = os.getenv(key)
111
- if not raw:
112
- return fallback
113
- try:
114
- values = [float(part.strip()) for part in raw.split(",")]
115
- return values if len(values) == 3 else fallback
116
- except ValueError:
117
- return fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/best.pt.version.txt DELETED
@@ -1 +0,0 @@
1
- 24_realworld_artifact_30ep
 
 
models/best_model.pt.version.txt DELETED
@@ -1 +0,0 @@
1
- 24_realworld_artifact_30ep
 
 
requirements.txt CHANGED
@@ -2,7 +2,5 @@ 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
 
2
  pyyaml>=6.0
3
  pillow>=10.0.0
4
  numpy>=1.24.0
 
 
5
  transformers>=4.38.0
6
  huggingface_hub>=0.24.0
schemas.py DELETED
@@ -1,23 +0,0 @@
1
- from pydantic import BaseModel, Field
2
-
3
-
4
- class AnalyzeResponse(BaseModel):
5
- score: float = Field(ge=0.0, le=1.0)
6
- verdict: str
7
- heatmap_b64: str
8
- overlay_b64: str
9
- model_version: str
10
- elapsed_ms: int
11
- # 관점별 실제 세부 점수(AI 확률). 모델이 지원할 때만 채워집니다.
12
- structure_score: float | None = Field(default=None, ge=0.0, le=1.0)
13
- context_score: float | None = Field(default=None, ge=0.0, le=1.0)
14
- detail_score: float | None = Field(default=None, ge=0.0, le=1.0)
15
-
16
-
17
- class VersionResponse(BaseModel):
18
- model_version: str
19
- model_path: str
20
-
21
-
22
- class HealthResponse(BaseModel):
23
- status: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
xai.py DELETED
@@ -1,157 +0,0 @@
1
- import base64
2
- import io
3
- from typing import Any, Optional
4
-
5
- import numpy as np
6
- import torch
7
- from PIL import Image
8
-
9
- CAM_SIZE = 224
10
-
11
-
12
- def png_b64(image: Image.Image) -> str:
13
- buffer = io.BytesIO()
14
- image.save(buffer, format="PNG")
15
- return base64.b64encode(buffer.getvalue()).decode("ascii")
16
-
17
-
18
- # ─── 합성(폴백) 히트맵 ────────────────────────────────────────────────
19
- # GradCAM 이 불가능한 경우(모델 미로드/예외)에만 사용하는 점수 기반 대체 시각화.
20
-
21
- def deterministic_heatmap(score: float, size: int = CAM_SIZE) -> Image.Image:
22
- x = np.linspace(0, 1, size, dtype=np.float32)
23
- y = np.linspace(0, 1, size, dtype=np.float32)
24
- xx, yy = np.meshgrid(x, y)
25
- center_x = 0.28 + (score * 0.44)
26
- center_y = 0.72 - (score * 0.38)
27
- primary = np.exp(-(((xx - center_x) ** 2) / 0.055 + ((yy - center_y) ** 2) / 0.085))
28
- secondary = np.exp(-(((xx - 0.72) ** 2) / 0.09 + ((yy - 0.28) ** 2) / 0.06)) * (0.35 + score * 0.35)
29
- heat = np.clip(primary + secondary, 0, 1)
30
- rgba = np.zeros((size, size, 4), dtype=np.uint8)
31
- rgba[..., 0] = (255 * heat).astype(np.uint8)
32
- rgba[..., 1] = (180 * (1 - heat)).astype(np.uint8)
33
- rgba[..., 2] = np.uint8(255 * (0.25 + score * 0.25))
34
- rgba[..., 3] = 220
35
- return Image.fromarray(rgba, "RGBA")
36
-
37
-
38
- def overlay_heatmap(original: Image.Image, heatmap: Image.Image, alpha: float = 0.5) -> Image.Image:
39
- base = original.convert("RGBA")
40
- heat = heatmap.convert("RGBA").resize(base.size)
41
- return Image.blend(base, heat, alpha).convert("RGB")
42
-
43
-
44
- # ─── ViT/CLIP 토큰 출력을 공간 특징맵으로 변환 ────────────────────────
45
- # 트랜스포머 레이어 출력은 [B, tokens, C] 형태이므로 CLS 토큰을 제거하고
46
- # [B, C, H, W] 격자로 reshape 해야 GradCAM 이 공간 히트맵을 만들 수 있습니다.
47
-
48
- def _vit_reshape_transform(tensor: torch.Tensor) -> torch.Tensor:
49
- tokens = tensor[:, 1:, :]
50
- b, n, c = tokens.shape
51
- side = int(round(n ** 0.5))
52
- tokens = tokens.reshape(b, side, side, c)
53
- return tokens.permute(0, 3, 1, 2)
54
-
55
-
56
- def _resolve_attr(model: Any, path: str) -> Optional[Any]:
57
- obj: Any = model
58
- for part in path.split("."):
59
- if part.endswith("]"):
60
- name, idx = part[:-1].split("[")
61
- obj = getattr(obj, name, None)
62
- if obj is None:
63
- return None
64
- try:
65
- obj = obj[int(idx)]
66
- except (IndexError, ValueError, TypeError):
67
- return None
68
- else:
69
- obj = getattr(obj, part, None)
70
- if obj is None:
71
- return None
72
- return obj
73
-
74
-
75
- def find_target_layer(model: Any) -> tuple[Optional[Any], bool]:
76
- """(target_layer, needs_reshape) 를 반환합니다.
77
-
78
- A-EYE 하이브리드 모델에서 의심 영역 시각화에 가장 적합한 타겟은
79
- 공간 정보를 유지하며 최종 판단에 기여하는 컨볼루션 브랜치입니다.
80
-
81
- - srm_branch(노이즈 잔차): 이미지 공간의 위·변조 흔적에 반응 → 1순위
82
- - fft_branch(주파수): 생성 모델 특유의 주파수 패턴 → 2순위
83
- - clip_encoder fc2: 트랜스포머(요청 시), CLS-only 풀링이면 신호가 약함 → 폴백
84
-
85
- (CLIP/ViT 브랜치는 CLS 토큰만 출력에 사용하므로 패치 토큰 기반
86
- GradCAM 이 사실상 무의미해, 공간 컨볼루션 브랜치를 우선합니다.)
87
- """
88
- srm = _resolve_attr(model, "srm_branch.encoder")
89
- if srm is not None:
90
- return srm, False
91
-
92
- fft = _resolve_attr(model, "fft_branch.conv")
93
- if fft is not None:
94
- return fft, False
95
-
96
- clip_fc2 = _resolve_attr(
97
- model, "clip_encoder.vision_model.vision_model.encoder.layers[-1].mlp.fc2"
98
- )
99
- if clip_fc2 is not None:
100
- return clip_fc2, True
101
-
102
- if not hasattr(model, "named_modules"):
103
- return None, False
104
- modules = list(model.named_modules())
105
- for name, module in reversed(modules):
106
- lowered = name.lower()
107
- if any(token in lowered for token in ("layer4", "conv", "blocks")):
108
- return module, False
109
- return (modules[-1][1] if modules else None), False
110
-
111
-
112
- def _colorize_cam(grayscale: np.ndarray) -> np.ndarray:
113
- """0~1 CAM 을 JET 컬러맵 RGB 이미지(uint8)로 변환."""
114
- gray_u8 = np.uint8(255 * np.clip(grayscale, 0, 1))
115
- try:
116
- import cv2
117
-
118
- colored = cv2.applyColorMap(gray_u8, cv2.COLORMAP_JET)
119
- return cv2.cvtColor(colored, cv2.COLOR_BGR2RGB)
120
- except Exception:
121
- rgb = np.zeros((*gray_u8.shape, 3), dtype=np.uint8)
122
- rgb[..., 0] = gray_u8
123
- rgb[..., 2] = 255 - gray_u8
124
- return rgb
125
-
126
-
127
- def gradcam_or_fallback(
128
- model: Any, input_tensor: torch.Tensor, original: Image.Image, score: float
129
- ) -> tuple[str, str]:
130
- try:
131
- from pytorch_grad_cam import GradCAM
132
- from pytorch_grad_cam.utils.image import show_cam_on_image
133
- from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
134
-
135
- target_layer, needs_reshape = find_target_layer(model)
136
- if target_layer is None:
137
- raise RuntimeError("No target layer")
138
-
139
- reshape = _vit_reshape_transform if needs_reshape else None
140
- target_idx = 1 if score >= 0.5 else 0
141
-
142
- with GradCAM(model=model, target_layers=[target_layer], reshape_transform=reshape) as cam:
143
- grayscale = cam(input_tensor=input_tensor, targets=[ClassifierOutputTarget(target_idx)])[0]
144
-
145
- grayscale = np.maximum(grayscale, 0)
146
- grayscale = grayscale / (grayscale.max() + 1e-8)
147
-
148
- rgb = np.asarray(original.resize((CAM_SIZE, CAM_SIZE)).convert("RGB"), dtype=np.float32) / 255.0
149
- overlay = show_cam_on_image(rgb, grayscale, use_rgb=True, image_weight=0.55)
150
- overlay_image = Image.fromarray(np.ascontiguousarray(overlay, dtype=np.uint8))
151
-
152
- heatmap_image = Image.fromarray(_colorize_cam(grayscale))
153
- return png_b64(heatmap_image), png_b64(overlay_image)
154
- except Exception:
155
- heatmap = deterministic_heatmap(score)
156
- overlay = overlay_heatmap(original, heatmap)
157
- return png_b64(heatmap), png_b64(overlay)