ar07xd commited on
Commit
f2a2ad5
Β·
verified Β·
1 Parent(s): 780a87a

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. api/v1/analyze.py +90 -30
  2. 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
- try:
293
- model_family = "efficientnet" if settings.ENSEMBLE_MODE else "vit"
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
- try:
305
- ela_b64 = generate_ela_base64(pil)
 
 
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
- try:
313
- boxes_b64 = generate_boxes_base64(pil)
 
 
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
- try:
321
- exif_summary = extract_exif(pil, raw)
 
 
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+14: LLM + VLM cards (authed users only β€” conserves LLM quota) ──
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. Returns URL-style path or None on failure."""
 
 
 
 
 
 
 
 
 
 
 
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(dest, "JPEG", quality=82, optimize=True)
93
- return f"/media/thumbs/{sha}_400.jpg"
 
94
  except Exception as e: # noqa: BLE001
95
- logger.warning(f"thumbnail generation failed for {sha}: {e}")
96
- return None
 
 
 
 
 
 
 
 
 
 
 
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: