Spaces:
Running
Running
Deploy A-EYE remote full ensemble backend
Browse files- .dockerignore +9 -4
- .env.example +0 -4
- .gitignore +0 -11
- DEPLOY.md +0 -95
- Dockerfile +1 -0
- README.md +9 -40
- artifact_model.py +0 -273
- inference.py +0 -134
- main.py +0 -67
- model_loader.py +0 -117
- models/best.pt.version.txt +0 -1
- models/best_model.pt.version.txt +0 -1
- requirements.txt +0 -2
- schemas.py +0 -23
- xai.py +0 -157
.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 |
-
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
```
|
| 18 |
-
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|