Spaces:
Paused
Paused
Mount FastAPI routes into Gradio space app
Browse files- README.md +13 -6
- app.py +65 -83
- requirements.txt +24 -10
- src/api/main.py +8 -3
- tests/test_api.py +3 -3
- tests/test_zero_gpu_contract.py +20 -14
README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
---
|
| 2 |
title: GenAI-DeepDetect
|
| 3 |
-
emoji: π
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: true
|
| 10 |
hardware: zero-gpu
|
|
@@ -13,8 +13,15 @@ license: mit
|
|
| 13 |
|
| 14 |
# GenAI-DeepDetect
|
| 15 |
|
| 16 |
-
Gradio
|
| 17 |
-
attribution.
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: GenAI-DeepDetect
|
| 3 |
+
emoji: "π"
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "5.23.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: true
|
| 10 |
hardware: zero-gpu
|
|
|
|
| 13 |
|
| 14 |
# GenAI-DeepDetect
|
| 15 |
|
| 16 |
+
Gradio Space with FastAPI routes mounted into the same ASGI app.
|
|
|
|
| 17 |
|
| 18 |
+
Frontend/API endpoints:
|
| 19 |
+
|
| 20 |
+
- `GET /health`
|
| 21 |
+
- `GET /health/models`
|
| 22 |
+
- `POST /detect/image`
|
| 23 |
+
- `POST /detect/video`
|
| 24 |
+
|
| 25 |
+
Interactive Gradio demo:
|
| 26 |
+
|
| 27 |
+
- `GET /gradio`
|
app.py
CHANGED
|
@@ -1,84 +1,58 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import
|
|
|
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
-
import
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
from
|
| 10 |
-
from
|
| 11 |
-
from modules.m3_sstgnn import SSTGNNModule
|
| 12 |
-
from modules.m5_explain import ExplainModule
|
| 13 |
-
from modules.m5_fusion import FusionModule
|
| 14 |
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
m5_fusion = FusionModule(weights_path="weights/fusion_mlp.pt")
|
| 25 |
-
m5_explain = ExplainModule()
|
| 26 |
-
print("Ready. GPU will be allocated per request via ZeroGPU.")
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
@spaces.GPU(duration=120)
|
| 30 |
-
def analyze(video_file: str | None):
|
| 31 |
-
if video_file is None:
|
| 32 |
-
return "Upload a video.", "", "", ""
|
| 33 |
-
|
| 34 |
-
start = time.time()
|
| 35 |
|
| 36 |
-
m1.to_gpu()
|
| 37 |
-
m2.to_gpu()
|
| 38 |
-
m3.to_gpu()
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
m2.to_cpu()
|
| 47 |
-
m3.to_cpu()
|
| 48 |
-
|
| 49 |
-
fusion = m5_fusion.fuse(r1["s1"], r2["s2"], r3["s3"])
|
| 50 |
-
explanation = m5_explain.explain(
|
| 51 |
-
fakescore=fusion["FakeScore"],
|
| 52 |
-
s1=r1["s1"],
|
| 53 |
-
s2=r2["s2"],
|
| 54 |
-
s3=r3["s3"],
|
| 55 |
-
weights=fusion["weights"],
|
| 56 |
-
attribution=r2["attribution"],
|
| 57 |
-
segments=r1.get("segments", []),
|
| 58 |
-
top_generator=r2["top_generator"],
|
| 59 |
)
|
|
|
|
| 60 |
|
| 61 |
-
elapsed = time.time() - start
|
| 62 |
-
verdict = "FAKE" if fusion["FakeScore"] > 0.5 else "REAL"
|
| 63 |
-
icon = "RED" if verdict == "FAKE" else "GREEN"
|
| 64 |
-
verdict_text = f"{icon} **{verdict}** (FakeScore: {fusion['FakeScore']:.3f})"
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
- Graph-GNN (M3): {r3['s3']:.3f} [weight: {fusion['weights']['graph_gnn']:.2f}]
|
| 70 |
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
return
|
| 82 |
|
| 83 |
|
| 84 |
with gr.Blocks(
|
|
@@ -87,30 +61,38 @@ with gr.Blocks(
|
|
| 87 |
) as demo:
|
| 88 |
gr.Markdown(
|
| 89 |
"# GenAI-DeepDetect\n"
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
"**Hardware:** ZeroGPU (A10G)"
|
| 93 |
)
|
| 94 |
|
| 95 |
with gr.Row():
|
| 96 |
with gr.Column(scale=1):
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
with gr.Column(scale=2):
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
btn.click(fn=analyze, inputs=[vid], outputs=[v_out, s_out, a_out, e_out])
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
if __name__ == "__main__":
|
| 116 |
-
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import io
|
| 4 |
+
import mimetypes
|
| 5 |
+
from pathlib import Path
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
+
import uvicorn
|
| 9 |
+
from fastapi import HTTPException, UploadFile
|
| 10 |
+
from starlette.datastructures import Headers
|
| 11 |
|
| 12 |
+
from src.api.main import IMAGE_TYPES, VIDEO_TYPES, app as api_app
|
| 13 |
+
from src.api.main import detect_image, detect_video
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
+
def _build_upload(path: str) -> UploadFile:
|
| 17 |
+
file_path = Path(path)
|
| 18 |
+
content_type = (mimetypes.guess_type(file_path.name)[0] or "application/octet-stream").lower()
|
| 19 |
+
return UploadFile(
|
| 20 |
+
filename=file_path.name,
|
| 21 |
+
file=io.BytesIO(file_path.read_bytes()),
|
| 22 |
+
headers=Headers({"content-type": content_type}),
|
| 23 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
def _response_to_markdown(response) -> tuple[str, str, dict]:
|
| 27 |
+
summary = (
|
| 28 |
+
f"## {response.verdict}\n"
|
| 29 |
+
f"Confidence: {response.confidence:.3f}\n\n"
|
| 30 |
+
f"Attributed generator: `{response.attributed_generator}`\n"
|
| 31 |
+
f"Processing time: `{response.processing_time_ms:.2f} ms`"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
+
return summary, response.explanation, response.model_dump()
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
async def analyze_media(file_path: str | None) -> tuple[str, str, dict]:
|
| 37 |
+
if not file_path:
|
| 38 |
+
return "Upload an image or video.", "", {}
|
|
|
|
| 39 |
|
| 40 |
+
upload = _build_upload(file_path)
|
| 41 |
+
content_type = (upload.content_type or "").split(";")[0].strip().lower()
|
| 42 |
|
| 43 |
+
try:
|
| 44 |
+
if content_type in IMAGE_TYPES:
|
| 45 |
+
response = await detect_image(upload)
|
| 46 |
+
elif content_type in VIDEO_TYPES:
|
| 47 |
+
response = await detect_video(upload)
|
| 48 |
+
else:
|
| 49 |
+
return f"Unsupported file type: `{content_type or 'unknown'}`", "", {}
|
| 50 |
+
except HTTPException as exc:
|
| 51 |
+
return f"Request failed: `{exc.status_code}`", str(exc.detail), {}
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
return "Analysis failed.", str(exc), {}
|
| 54 |
|
| 55 |
+
return _response_to_markdown(response)
|
| 56 |
|
| 57 |
|
| 58 |
with gr.Blocks(
|
|
|
|
| 61 |
) as demo:
|
| 62 |
gr.Markdown(
|
| 63 |
"# GenAI-DeepDetect\n"
|
| 64 |
+
"FastAPI routes stay at the Space root for your frontend.\n"
|
| 65 |
+
"This Gradio UI is mounted at `/gradio` on the same app."
|
|
|
|
| 66 |
)
|
| 67 |
|
| 68 |
with gr.Row():
|
| 69 |
with gr.Column(scale=1):
|
| 70 |
+
media = gr.File(
|
| 71 |
+
label="Upload Image or Video",
|
| 72 |
+
type="filepath",
|
| 73 |
+
file_types=["image", "video"],
|
| 74 |
+
)
|
| 75 |
+
analyze_btn = gr.Button("Analyze", variant="primary")
|
| 76 |
with gr.Column(scale=2):
|
| 77 |
+
verdict = gr.Markdown(label="Verdict")
|
| 78 |
+
explanation = gr.Markdown(label="Explanation")
|
| 79 |
+
|
| 80 |
+
details = gr.JSON(label="Detection Response")
|
| 81 |
+
analyze_btn.click(
|
| 82 |
+
fn=analyze_media,
|
| 83 |
+
inputs=[media],
|
| 84 |
+
outputs=[verdict, explanation, details],
|
| 85 |
+
)
|
| 86 |
|
|
|
|
| 87 |
|
| 88 |
+
app = gr.mount_gradio_app(
|
| 89 |
+
api_app,
|
| 90 |
+
demo,
|
| 91 |
+
path="/gradio",
|
| 92 |
+
show_error=True,
|
| 93 |
+
ssr_mode=False,
|
| 94 |
+
)
|
| 95 |
|
| 96 |
|
| 97 |
if __name__ == "__main__":
|
| 98 |
+
uvicorn.run(app, host="0.0.0.0", port=7860, workers=1)
|
requirements.txt
CHANGED
|
@@ -1,14 +1,28 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
torch>=2.1.0
|
| 3 |
torchvision>=0.16.0
|
| 4 |
torchaudio>=2.1.0
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
openai>=1.0.0
|
| 13 |
-
huggingface-hub>=0.
|
| 14 |
-
soundfile>=0.12.0
|
|
|
|
| 1 |
+
gradio==5.23.0
|
| 2 |
+
fastapi>=0.111.0
|
| 3 |
+
uvicorn[standard]>=0.29.0
|
| 4 |
+
python-multipart>=0.0.9
|
| 5 |
+
aiofiles>=23.2.1
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
python-dotenv>=1.0.1
|
| 8 |
+
pydantic>=2.7.0,<2.11
|
| 9 |
+
|
| 10 |
torch>=2.1.0
|
| 11 |
torchvision>=0.16.0
|
| 12 |
torchaudio>=2.1.0
|
| 13 |
+
transformers>=4.40.0
|
| 14 |
+
timm>=1.0.0
|
| 15 |
+
|
| 16 |
+
mediapipe>=0.10.14
|
| 17 |
+
opencv-python-headless>=4.9.0
|
| 18 |
+
librosa>=0.10.2
|
| 19 |
+
scipy>=1.13.0
|
| 20 |
+
facenet-pytorch>=2.5.3; python_version < "3.13"
|
| 21 |
+
|
| 22 |
+
Pillow>=10.3.0
|
| 23 |
+
numpy>=1.26.0
|
| 24 |
+
scikit-learn>=1.5.0
|
| 25 |
+
soundfile>=0.12.1
|
| 26 |
+
|
| 27 |
openai>=1.0.0
|
| 28 |
+
huggingface-hub>=0.23.0,<1.0
|
|
|
src/api/main.py
CHANGED
|
@@ -14,7 +14,7 @@ import numpy as np
|
|
| 14 |
from dotenv import load_dotenv
|
| 15 |
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 16 |
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
-
from fastapi.responses import HTMLResponse
|
| 18 |
from PIL import ExifTags, Image
|
| 19 |
|
| 20 |
from src.continual.novelty_detector import NoveltyDetector
|
|
@@ -252,8 +252,8 @@ def _model_inventory() -> dict[str, object]:
|
|
| 252 |
|
| 253 |
|
| 254 |
@app.get("/", response_class=HTMLResponse)
|
| 255 |
-
async def root() ->
|
| 256 |
-
return
|
| 257 |
|
| 258 |
|
| 259 |
@app.on_event("startup")
|
|
@@ -262,6 +262,11 @@ async def preload() -> None:
|
|
| 262 |
logger.info("Skipping startup preload in test mode")
|
| 263 |
return
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
logger.info("Preloading models...")
|
| 266 |
# Keep model imports/loads sequential to avoid lazy-import race issues.
|
| 267 |
await asyncio.to_thread(_fp._ensure)
|
|
|
|
| 14 |
from dotenv import load_dotenv
|
| 15 |
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 16 |
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 18 |
from PIL import ExifTags, Image
|
| 19 |
|
| 20 |
from src.continual.novelty_detector import NoveltyDetector
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
@app.get("/", response_class=HTMLResponse)
|
| 255 |
+
async def root() -> RedirectResponse:
|
| 256 |
+
return RedirectResponse(url="/gradio", status_code=307)
|
| 257 |
|
| 258 |
|
| 259 |
@app.on_event("startup")
|
|
|
|
| 262 |
logger.info("Skipping startup preload in test mode")
|
| 263 |
return
|
| 264 |
|
| 265 |
+
backend = get_inference_backend()
|
| 266 |
+
if backend in {"hf", "runpod"}:
|
| 267 |
+
logger.info("Skipping local model preload for backend=%s", backend)
|
| 268 |
+
return
|
| 269 |
+
|
| 270 |
logger.info("Preloading models...")
|
| 271 |
# Keep model imports/loads sequential to avoid lazy-import race issues.
|
| 272 |
await asyncio.to_thread(_fp._ensure)
|
tests/test_api.py
CHANGED
|
@@ -51,9 +51,9 @@ def test_health_models_returns_inventory(client):
|
|
| 51 |
# ββ GET / βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
|
| 53 |
def test_root_returns_html(client):
|
| 54 |
-
r = client.get("/")
|
| 55 |
-
assert r.status_code ==
|
| 56 |
-
assert
|
| 57 |
|
| 58 |
|
| 59 |
# ββ POST /detect/image ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 51 |
# ββ GET / βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
|
| 53 |
def test_root_returns_html(client):
|
| 54 |
+
r = client.get("/", follow_redirects=False)
|
| 55 |
+
assert r.status_code == 307
|
| 56 |
+
assert r.headers["location"] == "/gradio"
|
| 57 |
|
| 58 |
|
| 59 |
# ββ POST /detect/image ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
tests/test_zero_gpu_contract.py
CHANGED
|
@@ -23,26 +23,33 @@ def _load_module(path: str, name: str):
|
|
| 23 |
return module
|
| 24 |
|
| 25 |
|
| 26 |
-
def
|
| 27 |
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
| 28 |
|
| 29 |
-
assert "
|
| 30 |
-
assert
|
| 31 |
assert "app_file: app.py" in readme
|
| 32 |
|
| 33 |
|
| 34 |
-
def
|
| 35 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 36 |
-
tree = ast.parse(source)
|
| 37 |
|
| 38 |
-
assert "from
|
| 39 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
)
|
| 44 |
-
decorator_names = [ast.unparse(decorator) for decorator in analyze.decorator_list]
|
| 45 |
-
assert any(name.startswith("spaces.GPU(") for name in decorator_names)
|
| 46 |
|
| 47 |
|
| 48 |
def test_gpu_modules_expose_zero_gpu_device_transfer_methods():
|
|
@@ -69,10 +76,9 @@ def test_sstgnn_architecture_module_exists():
|
|
| 69 |
|
| 70 |
def test_required_space_files_exist():
|
| 71 |
for path in (
|
| 72 |
-
"
|
|
|
|
| 73 |
".env.example",
|
| 74 |
-
"weights/fusion_mlp.pt",
|
| 75 |
-
"lipfd/model.py",
|
| 76 |
):
|
| 77 |
assert (ROOT / path).exists()
|
| 78 |
|
|
|
|
| 23 |
return module
|
| 24 |
|
| 25 |
|
| 26 |
+
def test_readme_declares_gradio_space_metadata():
|
| 27 |
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
| 28 |
|
| 29 |
+
assert "sdk: gradio" in readme
|
| 30 |
+
assert 'sdk_version: "5.23.0"' in readme
|
| 31 |
assert "app_file: app.py" in readme
|
| 32 |
|
| 33 |
|
| 34 |
+
def test_app_mounts_gradio_onto_fastapi():
|
| 35 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
|
|
|
| 36 |
|
| 37 |
+
assert "from src.api.main import IMAGE_TYPES, VIDEO_TYPES, app as api_app" in source
|
| 38 |
+
assert "app = gr.mount_gradio_app(" in source
|
| 39 |
+
assert 'path="/gradio"' in source
|
| 40 |
+
assert 'uvicorn.run(app, host="0.0.0.0", port=7860, workers=1)' in source
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_api_root_redirects_to_gradio():
|
| 44 |
+
source = (ROOT / "src" / "api" / "main.py").read_text(encoding="utf-8")
|
| 45 |
+
tree = ast.parse(source)
|
| 46 |
|
| 47 |
+
assert "RedirectResponse" in source
|
| 48 |
+
assert 'return RedirectResponse(url="/gradio", status_code=307)' in source
|
| 49 |
+
assert any(
|
| 50 |
+
isinstance(node, ast.AsyncFunctionDef) and node.name == "root"
|
| 51 |
+
for node in tree.body
|
| 52 |
)
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def test_gpu_modules_expose_zero_gpu_device_transfer_methods():
|
|
|
|
| 76 |
|
| 77 |
def test_required_space_files_exist():
|
| 78 |
for path in (
|
| 79 |
+
"app.py",
|
| 80 |
+
"src/api/main.py",
|
| 81 |
".env.example",
|
|
|
|
|
|
|
| 82 |
):
|
| 83 |
assert (ROOT / path).exists()
|
| 84 |
|