Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- api/v1/analyze.py +90 -30
- services/storage.py +29 -10
api/v1/analyze.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import time
|
|
@@ -75,6 +76,20 @@ from utils.scoring import compute_authenticity_score, compute_video_authenticity
|
|
| 75 |
router = APIRouter(prefix="/analyze", tags=["analyze"])
|
| 76 |
|
| 77 |
IMAGE_MAX_MB = 20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
VIDEO_MAX_MB = 100
|
| 79 |
VIDEO_NUM_FRAMES = 16
|
| 80 |
|
|
@@ -208,6 +223,7 @@ def _store_llm_summary(payload: dict, summary: dict) -> None:
|
|
| 208 |
@limiter.limit(AUTH_ANALYZE, exempt_when=is_anon)
|
| 209 |
def generate_llm_endpoint(
|
| 210 |
request: Request,
|
|
|
|
| 211 |
record_id: int,
|
| 212 |
db: Session = Depends(get_db),
|
| 213 |
user: User | None = Depends(optional_current_user),
|
|
@@ -258,6 +274,7 @@ def _persist_response_payload(db: Session, record: AnalysisRecord, resp) -> None
|
|
| 258 |
async def analyze_image(
|
| 259 |
request: Request,
|
| 260 |
response: Response,
|
|
|
|
| 261 |
cache: bool = Query(default=True),
|
| 262 |
language_hint: str = Query(default="auto"),
|
| 263 |
file: UploadFile = File(...),
|
|
@@ -282,46 +299,70 @@ async def analyze_image(
|
|
| 282 |
return ImageAnalysisResponse.model_validate(payload)
|
| 283 |
|
| 284 |
pil = load_image_from_bytes(raw)
|
|
|
|
| 285 |
|
| 286 |
indicators = scan_artifacts(pil, raw)
|
| 287 |
stages.append("artifact_scanning")
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
# ββ Phase 12: Grad-CAM++ heatmap ββ
|
| 290 |
heatmap_status = "success"
|
| 291 |
heatmap = ""
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
heatmap, heatmap_source = generate_heatmap_base64(pil, model_family=model_family)
|
| 295 |
-
if not heatmap:
|
| 296 |
-
heatmap_status = heatmap_source # "none" or "fallback"
|
| 297 |
-
stages.append("heatmap_generation")
|
| 298 |
-
except Exception as e: # noqa: BLE001
|
| 299 |
-
logger.warning(f"Heatmap generation failed, continuing: {e}")
|
| 300 |
heatmap_status = "failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
# ββ Phase 12: ELA (Error Level Analysis) ββ
|
| 303 |
ela_b64 = ""
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
| 306 |
stages.append("ela_generation")
|
| 307 |
-
except Exception as e: # noqa: BLE001
|
| 308 |
-
logger.warning(f"ELA generation failed, continuing: {e}")
|
| 309 |
|
| 310 |
# ββ Phase 12: Bounding box mode ββ
|
| 311 |
boxes_b64 = ""
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
| 314 |
stages.append("boxes_generation")
|
| 315 |
-
except Exception as e: # noqa: BLE001
|
| 316 |
-
logger.warning(f"Bounding box generation failed, continuing: {e}")
|
| 317 |
|
| 318 |
# ββ Phase 12: EXIF extraction + trust adjustment ββ
|
| 319 |
exif_summary = None
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
| 322 |
stages.append("exif_extraction")
|
| 323 |
-
except Exception as e: # noqa: BLE001
|
| 324 |
-
logger.warning(f"EXIF extraction failed, continuing: {e}")
|
| 325 |
|
| 326 |
clf = classify_image(pil, artifact_indicators=indicators, exif=exif_summary)
|
| 327 |
stages.append("classification")
|
|
@@ -427,23 +468,42 @@ async def analyze_image(
|
|
| 427 |
resp.record_id = record.id
|
| 428 |
logger.info(f"Saved AnalysisRecord id={record.id} score={score} verdict={label}")
|
| 429 |
|
| 430 |
-
# ββ Phase 12
|
| 431 |
llm_summary = _compute_llm_summary(resp, record_id=record.id, user=user, media_kind="image", exclude=_IMAGE_EXCLUDE)
|
| 432 |
if llm_summary:
|
| 433 |
resp.explainability.llm_summary = llm_summary
|
| 434 |
stages.append("llm_explanation")
|
| 435 |
|
| 436 |
-
if user is not None and vlm_bd is None:
|
| 437 |
-
try:
|
| 438 |
-
vlm_bd = generate_vlm_breakdown(pil, record_id=str(record.id))
|
| 439 |
-
if vlm_bd:
|
| 440 |
-
resp.explainability.vlm_breakdown = vlm_bd
|
| 441 |
-
stages.append("vlm_breakdown")
|
| 442 |
-
except Exception as e: # noqa: BLE001
|
| 443 |
-
logger.warning(f"VLM breakdown failed, continuing: {e}")
|
| 444 |
-
|
| 445 |
resp.processing_summary.stages_completed = stages
|
| 446 |
_persist_response_payload(db, record, resp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
return resp
|
| 448 |
|
| 449 |
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import time
|
|
|
|
| 76 |
router = APIRouter(prefix="/analyze", tags=["analyze"])
|
| 77 |
|
| 78 |
IMAGE_MAX_MB = 20
|
| 79 |
+
_VIS_MAX_PX = 1024 # max pixel dimension for forensic visualizations
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _resize_for_vis(pil) -> "Image.Image":
|
| 83 |
+
"""Downsample to _VIS_MAX_PX on the longest side, preserving aspect ratio.
|
| 84 |
+
Forensic overlays (heatmap, ELA, boxes) don't need original resolution and
|
| 85 |
+
processing them full-size on large images is the primary latency bottleneck.
|
| 86 |
+
"""
|
| 87 |
+
from PIL import Image
|
| 88 |
+
w, h = pil.size
|
| 89 |
+
if max(w, h) <= _VIS_MAX_PX:
|
| 90 |
+
return pil
|
| 91 |
+
scale = _VIS_MAX_PX / max(w, h)
|
| 92 |
+
return pil.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
| 93 |
VIDEO_MAX_MB = 100
|
| 94 |
VIDEO_NUM_FRAMES = 16
|
| 95 |
|
|
|
|
| 223 |
@limiter.limit(AUTH_ANALYZE, exempt_when=is_anon)
|
| 224 |
def generate_llm_endpoint(
|
| 225 |
request: Request,
|
| 226 |
+
response: Response,
|
| 227 |
record_id: int,
|
| 228 |
db: Session = Depends(get_db),
|
| 229 |
user: User | None = Depends(optional_current_user),
|
|
|
|
| 274 |
async def analyze_image(
|
| 275 |
request: Request,
|
| 276 |
response: Response,
|
| 277 |
+
background_tasks: BackgroundTasks,
|
| 278 |
cache: bool = Query(default=True),
|
| 279 |
language_hint: str = Query(default="auto"),
|
| 280 |
file: UploadFile = File(...),
|
|
|
|
| 299 |
return ImageAnalysisResponse.model_validate(payload)
|
| 300 |
|
| 301 |
pil = load_image_from_bytes(raw)
|
| 302 |
+
pil_vis = _resize_for_vis(pil) # smaller copy for visualization β avoids 20-30s PNG encoding at 4K+
|
| 303 |
|
| 304 |
indicators = scan_artifacts(pil, raw)
|
| 305 |
stages.append("artifact_scanning")
|
| 306 |
|
| 307 |
+
model_family = "efficientnet" if settings.ENSEMBLE_MODE else "vit"
|
| 308 |
+
|
| 309 |
+
# ββ Run heatmap + ELA + boxes + EXIF in parallel ββ
|
| 310 |
+
def _run_heatmap():
|
| 311 |
+
return generate_heatmap_base64(pil_vis, model_family=model_family)
|
| 312 |
+
|
| 313 |
+
def _run_ela():
|
| 314 |
+
return generate_ela_base64(pil_vis)
|
| 315 |
+
|
| 316 |
+
def _run_boxes():
|
| 317 |
+
return generate_boxes_base64(pil_vis)
|
| 318 |
+
|
| 319 |
+
def _run_exif():
|
| 320 |
+
return extract_exif(pil, raw)
|
| 321 |
+
|
| 322 |
+
heatmap_result, ela_result, boxes_result, exif_result = await asyncio.gather(
|
| 323 |
+
asyncio.to_thread(_run_heatmap),
|
| 324 |
+
asyncio.to_thread(_run_ela),
|
| 325 |
+
asyncio.to_thread(_run_boxes),
|
| 326 |
+
asyncio.to_thread(_run_exif),
|
| 327 |
+
return_exceptions=True,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
# ββ Phase 12: Grad-CAM++ heatmap ββ
|
| 331 |
heatmap_status = "success"
|
| 332 |
heatmap = ""
|
| 333 |
+
if isinstance(heatmap_result, Exception):
|
| 334 |
+
logger.warning(f"Heatmap generation failed, continuing: {heatmap_result}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
heatmap_status = "failed"
|
| 336 |
+
else:
|
| 337 |
+
heatmap, heatmap_source = heatmap_result
|
| 338 |
+
if not heatmap:
|
| 339 |
+
heatmap_status = heatmap_source
|
| 340 |
+
else:
|
| 341 |
+
stages.append("heatmap_generation")
|
| 342 |
|
| 343 |
# ββ Phase 12: ELA (Error Level Analysis) ββ
|
| 344 |
ela_b64 = ""
|
| 345 |
+
if isinstance(ela_result, Exception):
|
| 346 |
+
logger.warning(f"ELA generation failed, continuing: {ela_result}")
|
| 347 |
+
else:
|
| 348 |
+
ela_b64 = ela_result
|
| 349 |
stages.append("ela_generation")
|
|
|
|
|
|
|
| 350 |
|
| 351 |
# ββ Phase 12: Bounding box mode ββ
|
| 352 |
boxes_b64 = ""
|
| 353 |
+
if isinstance(boxes_result, Exception):
|
| 354 |
+
logger.warning(f"Bounding box generation failed, continuing: {boxes_result}")
|
| 355 |
+
else:
|
| 356 |
+
boxes_b64 = boxes_result
|
| 357 |
stages.append("boxes_generation")
|
|
|
|
|
|
|
| 358 |
|
| 359 |
# ββ Phase 12: EXIF extraction + trust adjustment ββ
|
| 360 |
exif_summary = None
|
| 361 |
+
if isinstance(exif_result, Exception):
|
| 362 |
+
logger.warning(f"EXIF extraction failed, continuing: {exif_result}")
|
| 363 |
+
else:
|
| 364 |
+
exif_summary = exif_result
|
| 365 |
stages.append("exif_extraction")
|
|
|
|
|
|
|
| 366 |
|
| 367 |
clf = classify_image(pil, artifact_indicators=indicators, exif=exif_summary)
|
| 368 |
stages.append("classification")
|
|
|
|
| 468 |
resp.record_id = record.id
|
| 469 |
logger.info(f"Saved AnalysisRecord id={record.id} score={score} verdict={label}")
|
| 470 |
|
| 471 |
+
# ββ Phase 12: deterministic instant summary (no API call) ββ
|
| 472 |
llm_summary = _compute_llm_summary(resp, record_id=record.id, user=user, media_kind="image", exclude=_IMAGE_EXCLUDE)
|
| 473 |
if llm_summary:
|
| 474 |
resp.explainability.llm_summary = llm_summary
|
| 475 |
stages.append("llm_explanation")
|
| 476 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
resp.processing_summary.stages_completed = stages
|
| 478 |
_persist_response_payload(db, record, resp)
|
| 479 |
+
|
| 480 |
+
# ββ Phase 14: VLM breakdown runs after response is returned ββ
|
| 481 |
+
if user is not None and vlm_bd is None:
|
| 482 |
+
_record_id = record.id
|
| 483 |
+
_pil = pil
|
| 484 |
+
|
| 485 |
+
def _bg_vlm():
|
| 486 |
+
try:
|
| 487 |
+
from db.database import SessionLocal
|
| 488 |
+
breakdown = generate_vlm_breakdown(_pil, record_id=str(_record_id))
|
| 489 |
+
if not breakdown:
|
| 490 |
+
return
|
| 491 |
+
bg_db = SessionLocal()
|
| 492 |
+
try:
|
| 493 |
+
bg_rec = bg_db.query(AnalysisRecord).filter(AnalysisRecord.id == _record_id).first()
|
| 494 |
+
if bg_rec:
|
| 495 |
+
payload = json.loads(bg_rec.result_json)
|
| 496 |
+
payload.setdefault("explainability", {})["vlm_breakdown"] = breakdown.model_dump()
|
| 497 |
+
bg_rec.result_json = json.dumps(payload)
|
| 498 |
+
bg_db.commit()
|
| 499 |
+
logger.info(f"VLM breakdown persisted for record={_record_id}")
|
| 500 |
+
finally:
|
| 501 |
+
bg_db.close()
|
| 502 |
+
except Exception as e: # noqa: BLE001
|
| 503 |
+
logger.warning(f"Background VLM breakdown failed: {e}")
|
| 504 |
+
|
| 505 |
+
background_tasks.add_task(_bg_vlm)
|
| 506 |
+
|
| 507 |
return resp
|
| 508 |
|
| 509 |
|
services/storage.py
CHANGED
|
@@ -80,20 +80,39 @@ def save_file(src_path: str, sha: str, ext: str) -> str:
|
|
| 80 |
return f"/media/{rel.as_posix()}"
|
| 81 |
|
| 82 |
|
| 83 |
-
def make_image_thumbnail(pil: Image.Image, sha: str) -> str | None:
|
| 84 |
-
"""Write a 400px-max JPEG thumbnail.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
try:
|
| 86 |
-
_ensure_dirs()
|
| 87 |
-
dest = THUMB_DIR / f"{sha}_400.jpg"
|
| 88 |
-
if dest.exists():
|
| 89 |
-
return f"/media/thumbs/{sha}_400.jpg"
|
| 90 |
im = pil.convert("RGB").copy()
|
| 91 |
im.thumbnail((THUMB_MAX, THUMB_MAX))
|
| 92 |
-
im.save(
|
| 93 |
-
|
|
|
|
| 94 |
except Exception as e: # noqa: BLE001
|
| 95 |
-
logger.warning(f"thumbnail generation failed for {sha}: {e}")
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def make_video_thumbnail(video_path: str, sha: str) -> str | None:
|
|
|
|
| 80 |
return f"/media/{rel.as_posix()}"
|
| 81 |
|
| 82 |
|
| 83 |
+
def make_image_thumbnail(pil: Image.Image, sha: str) -> tuple[str | None, str | None]:
|
| 84 |
+
"""Write a 400px-max JPEG thumbnail.
|
| 85 |
+
|
| 86 |
+
Returns (url_path, data_url) where:
|
| 87 |
+
- url_path is the served asset path ("/media/thumbs/{sha}_400.jpg") or None
|
| 88 |
+
- data_url is a base64 JPEG data URL for inline embedding, or None on failure
|
| 89 |
+
The data URL is always generated (doesn't need file storage) so thumbnails
|
| 90 |
+
work even when persistent storage is unavailable.
|
| 91 |
+
"""
|
| 92 |
+
buf = io.BytesIO()
|
| 93 |
+
data_url: str | None = None
|
| 94 |
+
url_path: str | None = None
|
| 95 |
+
|
| 96 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
im = pil.convert("RGB").copy()
|
| 98 |
im.thumbnail((THUMB_MAX, THUMB_MAX))
|
| 99 |
+
im.save(buf, "JPEG", quality=75, optimize=True)
|
| 100 |
+
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
| 101 |
+
data_url = f"data:image/jpeg;base64,{b64}"
|
| 102 |
except Exception as e: # noqa: BLE001
|
| 103 |
+
logger.warning(f"thumbnail base64 generation failed for {sha}: {e}")
|
| 104 |
+
|
| 105 |
+
if data_url:
|
| 106 |
+
try:
|
| 107 |
+
_ensure_dirs()
|
| 108 |
+
dest = THUMB_DIR / f"{sha}_400.jpg"
|
| 109 |
+
if not dest.exists():
|
| 110 |
+
dest.write_bytes(buf.getvalue())
|
| 111 |
+
url_path = f"/media/thumbs/{sha}_400.jpg"
|
| 112 |
+
except Exception as e: # noqa: BLE001
|
| 113 |
+
logger.warning(f"thumbnail file save failed for {sha}: {e}")
|
| 114 |
+
|
| 115 |
+
return url_path, data_url
|
| 116 |
|
| 117 |
|
| 118 |
def make_video_thumbnail(video_path: str, sha: str) -> str | None:
|