akagtag commited on
Commit
5756499
Β·
1 Parent(s): 58c3ea0

Mount FastAPI routes into Gradio space app

Browse files
Files changed (6) hide show
  1. README.md +13 -6
  2. app.py +65 -83
  3. requirements.txt +24 -10
  4. src/api/main.py +8 -3
  5. tests/test_api.py +3 -3
  6. 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: '4.44.0'
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-based Hugging Face Space for multimodal deepfake detection and generator
17
- attribution.
18
 
19
- The app runs four modules per uploaded video: LipFD lip-sync detection, CLIP
20
- style fingerprinting, SSTGNN graph analysis, and NVIDIA NIM explanation.
 
 
 
 
 
 
 
 
 
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 os
4
- import time
 
5
 
6
  import gradio as gr
7
- import spaces
 
 
8
 
9
- from modules.m1_lipsync import LipSyncModule
10
- from modules.m2_fingerprint import FingerprintModule
11
- from modules.m3_sstgnn import SSTGNNModule
12
- from modules.m5_explain import ExplainModule
13
- from modules.m5_fusion import FusionModule
14
 
15
 
16
- CACHE = "/data/model_cache" if os.path.exists("/data") else "./cache"
17
- os.makedirs(CACHE, exist_ok=True)
18
- os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
19
-
20
- print("Loading modules on CPU...")
21
- m1 = LipSyncModule(cache_dir=CACHE)
22
- m2 = FingerprintModule(cache_dir=CACHE)
23
- m3 = SSTGNNModule(cache_dir=CACHE)
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
- try:
41
- r1 = m1.score(video_file)
42
- r2 = m2.score(video_file)
43
- r3 = m3.score(video_file)
44
- finally:
45
- m1.to_cpu()
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
- scores_text = f"""**Per-Module Scores:**
67
- - Lip-Sync (M1): {r1['s1']:.3f} [weight: {fusion['weights']['lip_sync']:.2f}]
68
- - Fingerprint (M2): {r2['s2']:.3f} [weight: {fusion['weights']['fingerprint']:.2f}]
69
- - Graph-GNN (M3): {r3['s3']:.3f} [weight: {fusion['weights']['graph_gnn']:.2f}]
70
 
71
- **Time:** {elapsed:.1f}s | **Hardware:** A10G (ZeroGPU)"""
 
72
 
73
- attr_text = "**Generator Attribution:**\n"
74
- if r2["attribution"]:
75
- for gen, prob in sorted(r2["attribution"].items(), key=lambda item: -item[1]):
76
- bar = "#" * int(prob * 30)
77
- attr_text += f"- {gen}: {prob * 100:.1f}% {bar}\n"
78
- else:
79
- attr_text += "- N/A (classified as real)"
 
 
 
 
80
 
81
- return verdict_text, scores_text, attr_text, explanation
82
 
83
 
84
  with gr.Blocks(
@@ -87,30 +61,38 @@ with gr.Blocks(
87
  ) as demo:
88
  gr.Markdown(
89
  "# GenAI-DeepDetect\n"
90
- "### Multimodal Deepfake Detection and Attribution\n"
91
- "**Modules:** LipFD | CLIP Detector | SSTGNN | Llama-3.1-8B via NVIDIA NIM | "
92
- "**Hardware:** ZeroGPU (A10G)"
93
  )
94
 
95
  with gr.Row():
96
  with gr.Column(scale=1):
97
- vid = gr.Video(label="Upload Video", height=300)
98
- btn = gr.Button("Analyze", variant="primary", size="lg")
 
 
 
 
99
  with gr.Column(scale=2):
100
- v_out = gr.Markdown(label="Verdict")
101
- s_out = gr.Markdown(label="Scores")
102
-
103
- with gr.Row():
104
- a_out = gr.Markdown(label="Attribution")
105
- e_out = gr.Markdown(label="Explanation")
 
 
 
106
 
107
- btn.click(fn=analyze, inputs=[vid], outputs=[v_out, s_out, a_out, e_out])
108
 
109
- gr.Markdown(
110
- "---\n**Paper:** GenAI-DeepDetect | "
111
- "**Authors:** Akshat Agarwal, Dev Chopda | SRM IST"
112
- )
 
 
 
113
 
114
 
115
  if __name__ == "__main__":
116
- demo.launch()
 
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
- spaces>=0.28.0
 
 
 
 
 
 
 
 
2
  torch>=2.1.0
3
  torchvision>=0.16.0
4
  torchaudio>=2.1.0
5
- torch-geometric>=2.4.0
6
- transformers>=4.36.0
7
- gradio>=4.44.0
8
- opencv-python-headless>=4.8.0
9
- librosa>=0.10.0
10
- numpy>=1.24.0
11
- Pillow>=10.0.0
 
 
 
 
 
 
 
12
  openai>=1.0.0
13
- huggingface-hub>=0.19.3,<1.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() -> HTMLResponse:
256
- return HTMLResponse("<h1>GenAI-DeepDetect API</h1><p>See /docs</p>")
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 == 200
56
- assert "text/html" in r.headers["content-type"]
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 test_readme_declares_zero_gpu_space_metadata():
27
  readme = (ROOT / "README.md").read_text(encoding="utf-8")
28
 
29
- assert "hardware: zero-gpu" in readme
30
- assert "sdk_version: '4.44.0'" in readme
31
  assert "app_file: app.py" in readme
32
 
33
 
34
- def test_app_uses_fallback_sstgnn_and_spaces_gpu_decorator():
35
  source = (ROOT / "app.py").read_text(encoding="utf-8")
36
- tree = ast.parse(source)
37
 
38
- assert "from modules.m3_fallback import SSTGNNModule" in source
39
- assert "import spaces" in source
 
 
 
 
 
 
 
40
 
41
- analyze = next(
42
- node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "analyze"
 
 
 
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
- "packages.txt",
 
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