Anish-530 commited on
Commit
817ad83
·
1 Parent(s): 1a27c86

Fixed Mobile support. Added a new AI media detection mechanism by Farid, that creates geometric lines. Fixed logs

Browse files
backend/app/ai/explanation_engine.py CHANGED
@@ -19,6 +19,8 @@ def generated_structured_explanation(features: Dict[str, float], prob: float) ->
19
 
20
  freq = features.get("frequency_score", 0.0)
21
  cnn = features.get("cnn_score", 0.0)
 
 
22
 
23
  if freq > 1.5:
24
  contributions["frequency"] = freq
@@ -34,6 +36,18 @@ def generated_structured_explanation(features: Dict[str, float], prob: float) ->
34
  "reason": f"CNN artifact score of {cnn:.2f} indicates structural anomalies in bounding edges (Target > 1.0)."
35
  })
36
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  if not region_analysis:
38
  region_analysis.append({
39
  "region": "Image-Wide",
 
19
 
20
  freq = features.get("frequency_score", 0.0)
21
  cnn = features.get("cnn_score", 0.0)
22
+ geometry_score = features.get("geometry_score")
23
+ geometry_message = features.get("geometry_message")
24
 
25
  if freq > 1.5:
26
  contributions["frequency"] = freq
 
36
  "reason": f"CNN artifact score of {cnn:.2f} indicates structural anomalies in bounding edges (Target > 1.0)."
37
  })
38
 
39
+ if geometry_score is not None and geometry_score < 50:
40
+ contributions["geometry"] = geometry_score
41
+ region_analysis.append({
42
+ "region": "Perspective & Structural Lines",
43
+ "reason": f"Perspective consistency score of {geometry_score:.1f}/100. {geometry_message}"
44
+ })
45
+ elif geometry_score is not None and geometry_score >= 75:
46
+ region_analysis.append({
47
+ "region": "Perspective & Structural Lines",
48
+ "reason": f"Perspective consistency score of {geometry_score:.1f}/100. {geometry_message}"
49
+ })
50
+
51
  if not region_analysis:
52
  region_analysis.append({
53
  "region": "Image-Wide",
backend/app/ai/geometry_detector.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import math
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def line_intersection(line1, line2):
9
+ """Find the intersection of two lines defined by (x1, y1, x2, y2)."""
10
+ x1, y1, x2, y2 = line1
11
+ x3, y3, x4, y4 = line2
12
+
13
+ denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
14
+ if denom == 0:
15
+ return None # Parallel lines
16
+
17
+ px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom
18
+ py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom
19
+ return (px, py)
20
+
21
+ def analyze_perspective(image_path: str, output_path: str) -> dict:
22
+ """
23
+ Reads an image, detects structural lines, estimates vanishing points/intersections,
24
+ and returns a geometric consistency score (0-100).
25
+ Saves an overlay image at output_path.
26
+ """
27
+ img = cv2.imread(image_path)
28
+ if img is None:
29
+ logger.error(f"Could not read image at {image_path} for perspective analysis")
30
+ return {"score": None, "message": "Failed to read image"}
31
+
32
+ original_h, original_w = img.shape[:2]
33
+
34
+ # Resize for performance and consistent thresholds
35
+ max_dim = 1024
36
+ scale = 1.0
37
+ if max(original_h, original_w) > max_dim:
38
+ scale = max_dim / max(original_h, original_w)
39
+ new_w, new_h = int(original_w * scale), int(original_h * scale)
40
+ process_img = cv2.resize(img, (new_w, new_h))
41
+ else:
42
+ process_img = img.copy()
43
+
44
+ gray = cv2.cvtColor(process_img, cv2.COLOR_BGR2GRAY)
45
+
46
+ # Adaptive edge detection
47
+ blur = cv2.GaussianBlur(gray, (5, 5), 0)
48
+ median_val = np.median(blur)
49
+ lower = int(max(0, 0.66 * median_val))
50
+ upper = int(min(255, 1.33 * median_val))
51
+ edges = cv2.Canny(blur, lower, upper)
52
+
53
+ # Line detection (HoughLinesP)
54
+ min_line_length = max(50, process_img.shape[0] * 0.1)
55
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=50, minLineLength=min_line_length, maxLineGap=20)
56
+
57
+ score = 50.0 # Default neutral
58
+ message = "Insufficient geometric structure for reliable perspective analysis."
59
+ intersections = []
60
+
61
+ # Overlay creation
62
+ overlay = process_img.copy()
63
+ overlay = cv2.convertScaleAbs(overlay, alpha=0.3, beta=0) # Darken original image
64
+
65
+ # Color definitions (BGR)
66
+ COLOR_LINE = (200, 200, 200) # Subtle white/gray for lines
67
+ COLOR_COHERENT = (200, 255, 100) # Cyan/Greenish
68
+ COLOR_INCOHERENT = (50, 100, 255) # Amber/Reddish
69
+
70
+ if lines is not None and len(lines) >= 4:
71
+ # Filter lines to remove strictly horizontal/vertical (which don't converge to distant VPs well)
72
+ filtered_lines = []
73
+ for line in lines:
74
+ x1, y1, x2, y2 = line[0]
75
+ angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
76
+ angle = abs(angle) % 180
77
+ # Keep lines that are somewhat diagonal
78
+ if (10 < angle < 80) or (100 < angle < 170):
79
+ filtered_lines.append(line[0])
80
+
81
+ if len(filtered_lines) >= 4:
82
+ # Limit to top 50 lines to avoid combinatorial explosion
83
+ filtered_lines = filtered_lines[:50]
84
+
85
+ # Find pairwise intersections
86
+ for i in range(len(filtered_lines)):
87
+ for j in range(i + 1, len(filtered_lines)):
88
+ pt = line_intersection(filtered_lines[i], filtered_lines[j])
89
+ if pt is not None:
90
+ # Only keep intersections that are somewhat reasonable (not infinite)
91
+ if -process_img.shape[1]*2 < pt[0] < process_img.shape[1]*3 and \
92
+ -process_img.shape[0]*2 < pt[1] < process_img.shape[0]*3:
93
+ intersections.append(pt)
94
+
95
+ status_color = COLOR_LINE
96
+ if len(intersections) > 5:
97
+ # Calculate cluster coherence (Median Absolute Deviation of intersections)
98
+ pts = np.array(intersections)
99
+ median_pt = np.median(pts, axis=0)
100
+ distances = np.linalg.norm(pts - median_pt, axis=1)
101
+ mad = np.median(distances)
102
+
103
+ # Normalize MAD by image diagonal
104
+ diagonal = math.sqrt(process_img.shape[0]**2 + process_img.shape[1]**2)
105
+ relative_dispersion = mad / diagonal
106
+
107
+ # Score mapping:
108
+ # relative_dispersion < 0.05 -> very coherent (score 90+)
109
+ # relative_dispersion > 0.25 -> very incoherent (score < 40)
110
+ # Using an exponential decay mapping to score
111
+ score = max(10, min(100, 100 * math.exp(-relative_dispersion * 5)))
112
+
113
+ if score >= 75:
114
+ message = "Perspective geometry appears physically coherent with a common vanishing-point structure."
115
+ status_color = COLOR_COHERENT
116
+ else:
117
+ message = "Structural perspective lines show inconsistent convergence behavior, which may indicate synthetic image generation."
118
+ status_color = COLOR_INCOHERENT
119
+
120
+ # Draw all lines
121
+ for x1, y1, x2, y2 in filtered_lines:
122
+ cv2.line(overlay, (x1, y1), (x2, y2), status_color, 1, cv2.LINE_AA)
123
+
124
+ # Draw intersections (dots)
125
+ for pt in intersections:
126
+ cv2.circle(overlay, (int(pt[0]), int(pt[1])), 2, status_color, -1)
127
+
128
+ # Draw median point
129
+ cv2.circle(overlay, (int(median_pt[0]), int(median_pt[1])), 8, (255, 255, 255), 2, cv2.LINE_AA)
130
+
131
+ else:
132
+ # Not enough lines/intersections
133
+ score = None
134
+
135
+ # Add text overlay
136
+ if score is not None:
137
+ cv2.putText(overlay, f"Perspective Consistency: {score:.1f}/100", (20, 40),
138
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2, cv2.LINE_AA)
139
+ else:
140
+ cv2.putText(overlay, "Perspective: Insufficient Geometry", (20, 40),
141
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (150, 150, 150), 2, cv2.LINE_AA)
142
+
143
+ # Resize back to original
144
+ if scale != 1.0:
145
+ overlay = cv2.resize(overlay, (original_w, original_h))
146
+
147
+ cv2.imwrite(output_path, overlay)
148
+
149
+ return {
150
+ "score": score,
151
+ "message": message
152
+ }
backend/app/api/file_routes.py CHANGED
@@ -42,6 +42,8 @@ def format_file_response(f):
42
  "filename": f.filename,
43
  "filepath": active_storage.get_presigned_url(f.filepath) if f.filepath else None,
44
  "heatmap_path": active_storage.get_presigned_url(f.heatmap_path) if f.heatmap_path else None,
 
 
45
  "type": f.filetype,
46
  "size": f.filesize,
47
  "status": f.status,
 
42
  "filename": f.filename,
43
  "filepath": active_storage.get_presigned_url(f.filepath) if f.filepath else None,
44
  "heatmap_path": active_storage.get_presigned_url(f.heatmap_path) if f.heatmap_path else None,
45
+ "geometry_path": active_storage.get_presigned_url(f.geometry_path) if getattr(f, "geometry_path", None) else None,
46
+ "geometry_score": getattr(f, "geometry_score", None),
47
  "type": f.filetype,
48
  "size": f.filesize,
49
  "status": f.status,
backend/app/core/logger.py CHANGED
@@ -1,47 +1,64 @@
1
  import sys
2
  import os
3
- import requests as _requests
4
  from loguru import logger
5
  from app.core.config import settings
6
  from pathlib import Path
7
  from app.core.request_id import get_request_id
8
 
9
- # 1. Clean up default loguru handler
10
  logger.remove()
11
 
12
- # 2. Inject the request ID into loguru's "extra" dictionary globally
13
- def inject_request_id(record):
14
  req_id = get_request_id()
15
  record["extra"]["request_id"] = req_id if req_id else "SYSTEM"
16
 
17
- logger.configure(patcher=inject_request_id)
18
 
19
- # 3. Create log directory
20
  log_dir = Path("app_logs")
21
  log_dir.mkdir(parents=True, exist_ok=True)
22
 
23
- # 4. Console Logger (JSON Serialized)
24
- logger.add(
25
- sys.stdout,
26
- format="{message}",
27
- serialize=True,
28
- level=settings.LOG_LEVEL,
29
- enqueue=True
30
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- # 5. File Logger
33
  logger.add(
34
  "app_logs/api_{time:YYYY-MM-DD}.log",
35
  rotation="00:00",
36
  retention="30 days",
37
  compression="zip",
38
  level="INFO",
39
- format="{time:YYYY-MM-DD HH:mm:ss} | {level} | [{extra[request_id]}] {name}:{function}:{line} - {message}",
40
- enqueue=True
 
41
  )
42
 
43
- # 6. New Relic Cloud Sink (fully optional — never crashes the app)
44
- # Set NEW_RELIC_LICENSE_KEY and NEW_RELIC_APP_NAME in your environment.
45
  _NR_KEY = os.environ.get("NEW_RELIC_LICENSE_KEY", "")
46
  if _NR_KEY:
47
  try:
@@ -54,25 +71,26 @@ if _NR_KEY:
54
  def _newrelic_sink(message):
55
  record = message.record
56
  try:
57
- # Send to New Relic Logs APM
58
  newrelic.agent.record_log_event(
59
  message=record["message"],
60
  level=record["level"].name,
61
  timestamp=int(record["time"].timestamp() * 1000),
62
  attributes={
63
  "request_id": record["extra"].get("request_id", "SYSTEM"),
64
- "logger": record["name"],
65
- "function": record["function"],
66
- "line": record["line"],
 
 
 
 
67
  }
68
  )
69
  except Exception:
70
- pass # Never let cloud logging failure affect the app
71
 
72
  logger.add(_newrelic_sink, level="INFO", enqueue=True)
73
  except ImportError:
74
- # newrelic package not installed — skip silently
75
  pass
76
  except Exception:
77
- # Any other error during setup — skip silently
78
  pass
 
1
  import sys
2
  import os
 
3
  from loguru import logger
4
  from app.core.config import settings
5
  from pathlib import Path
6
  from app.core.request_id import get_request_id
7
 
8
+ # ── 1. Remove default loguru handler ──────────────────────────────────────────
9
  logger.remove()
10
 
11
+ # ── 2. Inject request_id into every log record ───────────────────────────────
12
+ def _inject_request_id(record):
13
  req_id = get_request_id()
14
  record["extra"]["request_id"] = req_id if req_id else "SYSTEM"
15
 
16
+ logger.configure(patcher=_inject_request_id)
17
 
18
+ # ── 3. Log directory ──────────────────────────────────────────────────────────
19
  log_dir = Path("app_logs")
20
  log_dir.mkdir(parents=True, exist_ok=True)
21
 
22
+ # ── 4. Console structured JSON for production, colorized text for local ─────
23
+ IS_PROD = os.environ.get("ENVIRONMENT", "development").lower() in ("production", "prod")
24
+
25
+ if IS_PROD:
26
+ # Production: pure JSON, machine-readable — timestamp in 12-hour AM/PM UTC
27
+ logger.add(
28
+ sys.stdout,
29
+ format="{message}",
30
+ serialize=True, # emits {"text":..., "record":{...}} JSON lines
31
+ level=settings.LOG_LEVEL,
32
+ enqueue=True,
33
+ )
34
+ else:
35
+ # Local dev: human-readable with color, 12-hour AM/PM format
36
+ logger.add(
37
+ sys.stdout,
38
+ format=(
39
+ "<green>{time:MM-DD-YYYY hh:mm:ss A}</green> | "
40
+ "<level>{level:<8}</level> | "
41
+ "<cyan>[{extra[request_id]}]</cyan> "
42
+ "<white>{name}:{function}:{line}</white> — {message}"
43
+ ),
44
+ colorize=True,
45
+ level=settings.LOG_LEVEL,
46
+ enqueue=True,
47
+ )
48
 
49
+ # ── 5. File logger — 12-hour AM/PM timestamp ─────────────────────────────────
50
  logger.add(
51
  "app_logs/api_{time:YYYY-MM-DD}.log",
52
  rotation="00:00",
53
  retention="30 days",
54
  compression="zip",
55
  level="INFO",
56
+ # hh = 12-hour clock, A = AM/PM
57
+ format="{time:MM-DD-YYYY hh:mm:ss A} | {level:<8} | [{extra[request_id]}] {name}:{function}:{line} — {message}",
58
+ enqueue=True,
59
  )
60
 
61
+ # ── 6. New Relic sink (optional — never crashes the app) ──────────────────────
 
62
  _NR_KEY = os.environ.get("NEW_RELIC_LICENSE_KEY", "")
63
  if _NR_KEY:
64
  try:
 
71
  def _newrelic_sink(message):
72
  record = message.record
73
  try:
 
74
  newrelic.agent.record_log_event(
75
  message=record["message"],
76
  level=record["level"].name,
77
  timestamp=int(record["time"].timestamp() * 1000),
78
  attributes={
79
  "request_id": record["extra"].get("request_id", "SYSTEM"),
80
+ "method": record["extra"].get("method", ""),
81
+ "path": record["extra"].get("path", ""),
82
+ "status": record["extra"].get("status", ""),
83
+ "duration_ms": record["extra"].get("duration_ms", ""),
84
+ "logger": record["name"],
85
+ "function": record["function"],
86
+ "line": record["line"],
87
  }
88
  )
89
  except Exception:
90
+ pass # Cloud logging must never affect the app
91
 
92
  logger.add(_newrelic_sink, level="INFO", enqueue=True)
93
  except ImportError:
 
94
  pass
95
  except Exception:
 
96
  pass
backend/app/core/logging_middleware.py CHANGED
@@ -1,33 +1,49 @@
1
  import time
 
2
  from fastapi import Request
3
  from starlette.middleware.base import BaseHTTPMiddleware
4
  from app.core.logger import logger
5
- from app.core.request_id import generate_request_id
 
 
6
 
7
  class APILoggingMiddleware(BaseHTTPMiddleware):
8
  async def dispatch(self, request: Request, call_next):
9
- req_id = generate_request_id()
10
- start_time = time.time()
 
11
 
12
- logger.info(f"Incoming Request: {request.method} {request.url.path}")
13
  try:
14
  response = await call_next(request)
15
- process_time = time.time() - start_time
 
 
 
 
 
 
 
 
16
 
17
- logger.info(
18
- f"Request Completed: {request.method} {request.url.path} "
19
- f"- Status: {response.status_code} "
20
- f"in {process_time:.4f}s"
21
- )
22
 
23
- response.headers["X-Request-ID"] = req_id
 
24
  return response
25
-
26
- except Exception as exc:
27
- process_time = time.time() - start_time
28
- logger.error(
29
- f"Request Failed: {request.method} {request.url.path} "
30
- f"- Error: {str(exc)} "
31
- f"in {process_time:.4f}s"
32
- )
33
- raise
 
 
 
 
 
 
 
 
1
  import time
2
+ import os
3
  from fastapi import Request
4
  from starlette.middleware.base import BaseHTTPMiddleware
5
  from app.core.logger import logger
6
+
7
+ # Paths that are too noisy to log at INFO — only log if they error
8
+ _QUIET_PATHS = frozenset(["/health", "/favicon.ico", "/metrics", "/docs", "/openapi.json", "/redoc"])
9
 
10
  class APILoggingMiddleware(BaseHTTPMiddleware):
11
  async def dispatch(self, request: Request, call_next):
12
+ start_time = time.perf_counter()
13
+ path = request.url.path
14
+ method = request.method
15
 
 
16
  try:
17
  response = await call_next(request)
18
+ except Exception as exc:
19
+ duration_ms = round((time.perf_counter() - start_time) * 1000, 1)
20
+ logger.bind(
21
+ method=method,
22
+ path=path,
23
+ status=500,
24
+ duration_ms=duration_ms,
25
+ ).error(f"Unhandled exception on {method} {path} — {type(exc).__name__}: {exc}")
26
+ raise
27
 
28
+ duration_ms = round((time.perf_counter() - start_time) * 1000, 1)
29
+ status = response.status_code
 
 
 
30
 
31
+ # Suppress noisy health-check / static paths unless they error
32
+ if path in _QUIET_PATHS and status < 400:
33
  return response
34
+
35
+ # Route logs by severity so production dashboards can filter cleanly
36
+ bound = logger.bind(method=method, path=path, status=status, duration_ms=duration_ms)
37
+
38
+ if status >= 500:
39
+ bound.error(f"{method} {path} {status} ({duration_ms}ms)")
40
+ elif status >= 400:
41
+ # 401 on /auth/me is expected background noise — demote to DEBUG
42
+ if path == "/auth/me" and status == 401:
43
+ bound.debug(f"{method} {path} → {status} ({duration_ms}ms)")
44
+ else:
45
+ bound.warning(f"{method} {path} → {status} ({duration_ms}ms)")
46
+ else:
47
+ bound.info(f"{method} {path} → {status} ({duration_ms}ms)")
48
+
49
+ return response
backend/app/core/processor.py CHANGED
@@ -14,6 +14,7 @@ from app.ai.feature_extractor import extract_features
14
  from app.ai.attribution import generate_attribution
15
  from app.ai.explanation_engine import generated_structured_explanation
16
  from app.ai.explanation_formatter import format_explanation_with_llm
 
17
  from app.core.storage import active_storage
18
  import os
19
 
@@ -27,8 +28,10 @@ def process_file(file_id: int, db: Session):
27
  local_path = active_storage.download_to_temp(file.filepath)
28
 
29
  safe_heatmap_name = f"{uuid.uuid4().hex}.png"
 
30
  os.makedirs("uploads/heatmaps", exist_ok=True)
31
  local_heatmap_path = f"uploads/heatmaps/{safe_heatmap_name}"
 
32
 
33
  file.status = "PROCESSING"
34
  active_version = model_loader.get_latest_model_version()
@@ -41,6 +44,11 @@ def process_file(file_id: int, db: Session):
41
  label, prob = predict_ai(features["frequency_score"], features["cnn_score"])
42
  attribution_data = generate_attribution(local_path, local_heatmap_path)
43
 
 
 
 
 
 
44
  class MockFile:
45
  def __init__(self, f):
46
  self.file = f
@@ -50,7 +58,15 @@ def process_file(file_id: int, db: Session):
50
  mock_hf = MockFile(hf)
51
  r2_heatmap_key = active_storage.save(mock_hf, f"heatmaps/{safe_heatmap_name}")
52
 
 
 
 
 
 
 
 
53
  file.heatmap_path = r2_heatmap_key
 
54
  structured_reasoning = generated_structured_explanation(features, prob)
55
  natural_reasoning = format_explanation_with_llm(structured_reasoning)
56
 
@@ -68,6 +84,8 @@ def process_file(file_id: int, db: Session):
68
  os.remove(local_path)
69
  if 'local_heatmap_path' in locals() and os.path.exists(local_heatmap_path):
70
  os.remove(local_heatmap_path)
 
 
71
 
72
  db.commit()
73
  db.close()
 
14
  from app.ai.attribution import generate_attribution
15
  from app.ai.explanation_engine import generated_structured_explanation
16
  from app.ai.explanation_formatter import format_explanation_with_llm
17
+ from app.ai.geometry_detector import analyze_perspective
18
  from app.core.storage import active_storage
19
  import os
20
 
 
28
  local_path = active_storage.download_to_temp(file.filepath)
29
 
30
  safe_heatmap_name = f"{uuid.uuid4().hex}.png"
31
+ safe_geometry_name = f"geom_{uuid.uuid4().hex}.png"
32
  os.makedirs("uploads/heatmaps", exist_ok=True)
33
  local_heatmap_path = f"uploads/heatmaps/{safe_heatmap_name}"
34
+ local_geometry_path = f"uploads/heatmaps/{safe_geometry_name}"
35
 
36
  file.status = "PROCESSING"
37
  active_version = model_loader.get_latest_model_version()
 
44
  label, prob = predict_ai(features["frequency_score"], features["cnn_score"])
45
  attribution_data = generate_attribution(local_path, local_heatmap_path)
46
 
47
+ # Geometry Perspective Analysis
48
+ geom_result = analyze_perspective(local_path, local_geometry_path)
49
+ features["geometry_score"] = geom_result["score"]
50
+ features["geometry_message"] = geom_result["message"]
51
+
52
  class MockFile:
53
  def __init__(self, f):
54
  self.file = f
 
58
  mock_hf = MockFile(hf)
59
  r2_heatmap_key = active_storage.save(mock_hf, f"heatmaps/{safe_heatmap_name}")
60
 
61
+ if os.path.exists(local_geometry_path):
62
+ with open(local_geometry_path, "rb") as gf:
63
+ mock_gf = MockFile(gf)
64
+ r2_geometry_key = active_storage.save(mock_gf, f"heatmaps/{safe_geometry_name}")
65
+ file.geometry_path = r2_geometry_key
66
+
67
+ file.geometry_score = geom_result["score"]
68
  file.heatmap_path = r2_heatmap_key
69
+
70
  structured_reasoning = generated_structured_explanation(features, prob)
71
  natural_reasoning = format_explanation_with_llm(structured_reasoning)
72
 
 
84
  os.remove(local_path)
85
  if 'local_heatmap_path' in locals() and os.path.exists(local_heatmap_path):
86
  os.remove(local_heatmap_path)
87
+ if 'local_geometry_path' in locals() and os.path.exists(local_geometry_path):
88
+ os.remove(local_geometry_path)
89
 
90
  db.commit()
91
  db.close()
backend/app/models/file_model.py CHANGED
@@ -11,6 +11,8 @@ class File(Base):
11
  filetype = Column(String, nullable=False)
12
  filesize = Column(Integer, nullable=False)
13
  heatmap_path = Column(String, nullable=True)
 
 
14
  timeline_data = Column(String, nullable=True)
15
  ip_address = Column(String, nullable=True)
16
 
 
11
  filetype = Column(String, nullable=False)
12
  filesize = Column(Integer, nullable=False)
13
  heatmap_path = Column(String, nullable=True)
14
+ geometry_path = Column(String, nullable=True)
15
+ geometry_score = Column(Float, nullable=True)
16
  timeline_data = Column(String, nullable=True)
17
  ip_address = Column(String, nullable=True)
18
 
backend/app/worker/celery_app.py CHANGED
@@ -1,16 +1,28 @@
 
1
  from celery import Celery
2
  from app.core.config import settings
3
 
4
  redis_url = settings.REDIS_URL
5
- if redis_url.startswith("rediss://") and "ssl_cert_reqs=" not in redis_url:
6
- delimiter = "&" if "?" in redis_url else "?"
7
- redis_url = f"{redis_url}{delimiter}ssl_cert_reqs=CERT_NONE"
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  celery_app = Celery(
10
  "worker",
11
  broker=redis_url,
12
  backend=redis_url,
13
- include=["app.worker.tasks"]
14
  )
15
 
16
  celery_app.conf.update(
@@ -19,4 +31,7 @@ celery_app.conf.update(
19
  result_serializer="json",
20
  timezone="UTC",
21
  enable_utc=True,
 
 
 
22
  )
 
1
+ import ssl
2
  from celery import Celery
3
  from app.core.config import settings
4
 
5
  redis_url = settings.REDIS_URL
6
+
7
+ # SSL configuration for production Redis (Upstash / Redis Cloud / etc.)
8
+ # We use proper cert verification instead of the insecure CERT_NONE override.
9
+ _broker_ssl = None
10
+ _backend_ssl = None
11
+
12
+ if redis_url.startswith("rediss://"):
13
+ # CERT_REQUIRED with the system CA bundle — correct for managed Redis providers
14
+ _ssl_opts = {
15
+ "ssl_cert_reqs": ssl.CERT_REQUIRED,
16
+ "ssl_ca_certs": ssl.get_default_verify_paths().cafile, # system CA bundle
17
+ }
18
+ _broker_ssl = _ssl_opts
19
+ _backend_ssl = _ssl_opts
20
 
21
  celery_app = Celery(
22
  "worker",
23
  broker=redis_url,
24
  backend=redis_url,
25
+ include=["app.worker.tasks"],
26
  )
27
 
28
  celery_app.conf.update(
 
31
  result_serializer="json",
32
  timezone="UTC",
33
  enable_utc=True,
34
+ # Apply SSL config only when connecting to rediss:// endpoints
35
+ broker_use_ssl=_broker_ssl,
36
+ redis_backend_use_ssl=_backend_ssl,
37
  )
frontend/app/result/[id]/page.tsx CHANGED
@@ -21,6 +21,8 @@ interface FileResult {
21
  uploaded_at: string;
22
  confidence?: number;
23
  ai_explanation?: string;
 
 
24
  }
25
 
26
  export default function ResultPage() {
@@ -39,6 +41,7 @@ export default function ResultPage() {
39
  const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
40
  const [mediaLoaded, setMediaLoaded] = useState(false);
41
  const [heatmapLoaded, setHeatmapLoaded] = useState(false);
 
42
 
43
  // Feedback States
44
  const [showFeedbackPopup, setShowFeedbackPopup] = useState(false);
@@ -132,11 +135,13 @@ export default function ResultPage() {
132
 
133
  let mediaUrl = "";
134
  let heatmapUrl: string | null = null;
 
135
  let label = 'AUTHENTIC';
136
  let verdictStatus: 'AI' | 'SUSPICIOUS' | 'REAL' = 'REAL';
137
  let confidenceVal: number | null = null;
138
  let freqScore: number | null = null;
139
  let cnnScore: number | null = null;
 
140
  let nsfwScore: number | string | null = null;
141
 
142
  if (fileData) {
@@ -170,6 +175,20 @@ export default function ResultPage() {
170
  }
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  if (fileData.result) {
174
  const parts = fileData.result.split(/\r?\n/);
175
  const rawLabel = parts[0].trim();
@@ -198,6 +217,7 @@ export default function ResultPage() {
198
  if (nsfwMatch) nsfwScore = isNaN(parseFloat(nsfwMatch[1])) ? nsfwMatch[1] : parseFloat(nsfwMatch[1]);
199
  }
200
  confidenceVal = fileData.confidence ?? null;
 
201
  }
202
 
203
  // Dynamic Tag Extractor
@@ -357,7 +377,7 @@ export default function ResultPage() {
357
  />
358
 
359
  {/* Heatmap Overlay (Clipped) */}
360
- {heatmapUrl && (
361
  <img
362
  src={heatmapUrl}
363
  alt="Heatmap/Noise Pattern"
@@ -367,6 +387,16 @@ export default function ResultPage() {
367
  />
368
  )}
369
 
 
 
 
 
 
 
 
 
 
 
370
  {/* Slider Divider Line */}
371
  {heatmapUrl && (
372
  <div
@@ -378,7 +408,7 @@ export default function ResultPage() {
378
  )}
379
 
380
  {/* Interactive Invisible Slider */}
381
- {heatmapUrl && (
382
  <input
383
  type="range"
384
  min="0"
@@ -394,6 +424,28 @@ export default function ResultPage() {
394
  {/* Scanline overlay */}
395
  <div className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay z-50" style={{ background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, #fff 2px, #fff 4px)' }}></div>
396
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  </div>
398
 
399
  {/* Right Side: Analysis */}
@@ -421,7 +473,7 @@ export default function ResultPage() {
421
  }`}>
422
  Analysis Verdict
423
  </span>
424
- <h1 className="text-5xs md:text-6xl font-black tracking-tight text-[var(--theme-text)] my-2">
425
  {label}
426
  </h1>
427
  {confidenceVal !== null && (
@@ -478,7 +530,26 @@ export default function ResultPage() {
478
  </div>
479
  </div>
480
 
481
- <div className="upload-glass p-6 rounded-2xl flex flex-col gap-4 !cursor-none md:col-span-2 relative overflow-hidden group">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  <div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-[var(--theme-border)]/30 to-transparent transform -translate-x-full group-hover:translate-x-full transition-transform duration-1000 delay-200"></div>
483
  <Hash className="w-6 h-6 text-[var(--theme-text)] opacity-80" />
484
  <div>
 
21
  uploaded_at: string;
22
  confidence?: number;
23
  ai_explanation?: string;
24
+ geometry_path?: string | null;
25
+ geometry_score?: number | null;
26
  }
27
 
28
  export default function ResultPage() {
 
41
  const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
42
  const [mediaLoaded, setMediaLoaded] = useState(false);
43
  const [heatmapLoaded, setHeatmapLoaded] = useState(false);
44
+ const [showGeometryLayer, setShowGeometryLayer] = useState(false);
45
 
46
  // Feedback States
47
  const [showFeedbackPopup, setShowFeedbackPopup] = useState(false);
 
135
 
136
  let mediaUrl = "";
137
  let heatmapUrl: string | null = null;
138
+ let geometryUrl: string | null = null;
139
  let label = 'AUTHENTIC';
140
  let verdictStatus: 'AI' | 'SUSPICIOUS' | 'REAL' = 'REAL';
141
  let confidenceVal: number | null = null;
142
  let freqScore: number | null = null;
143
  let cnnScore: number | null = null;
144
+ let geometryScore: number | null = null;
145
  let nsfwScore: number | string | null = null;
146
 
147
  if (fileData) {
 
175
  }
176
  }
177
 
178
+ if (fileData.geometry_path) {
179
+ if (fileData.geometry_path.startsWith('http://') || fileData.geometry_path.startsWith('https://')) {
180
+ geometryUrl = fileData.geometry_path;
181
+ } else {
182
+ const gp = fileData.geometry_path.replace(/\\/g, '/');
183
+ const match = gp.match(/uploads[/].*$/);
184
+ if (match) {
185
+ geometryUrl = `http://localhost:8000/static/${match[0]}`;
186
+ } else {
187
+ geometryUrl = `http://localhost:8000/static/${gp.startsWith('/') ? gp.slice(1) : gp}`;
188
+ }
189
+ }
190
+ }
191
+
192
  if (fileData.result) {
193
  const parts = fileData.result.split(/\r?\n/);
194
  const rawLabel = parts[0].trim();
 
217
  if (nsfwMatch) nsfwScore = isNaN(parseFloat(nsfwMatch[1])) ? nsfwMatch[1] : parseFloat(nsfwMatch[1]);
218
  }
219
  confidenceVal = fileData.confidence ?? null;
220
+ geometryScore = fileData.geometry_score ?? null;
221
  }
222
 
223
  // Dynamic Tag Extractor
 
377
  />
378
 
379
  {/* Heatmap Overlay (Clipped) */}
380
+ {heatmapUrl && !showGeometryLayer && (
381
  <img
382
  src={heatmapUrl}
383
  alt="Heatmap/Noise Pattern"
 
387
  />
388
  )}
389
 
390
+ {/* Geometry Overlay (Clipped) */}
391
+ {geometryUrl && showGeometryLayer && (
392
+ <img
393
+ src={geometryUrl}
394
+ alt="Perspective Geometry"
395
+ className="absolute inset-0 w-full h-full object-contain !cursor-none z-20 pointer-events-none mix-blend-screen"
396
+ style={{ clipPath: `inset(0 ${100 - sliderValue}% 0 0)` }}
397
+ />
398
+ )}
399
+
400
  {/* Slider Divider Line */}
401
  {heatmapUrl && (
402
  <div
 
408
  )}
409
 
410
  {/* Interactive Invisible Slider */}
411
+ {(heatmapUrl || geometryUrl) && (
412
  <input
413
  type="range"
414
  min="0"
 
424
  {/* Scanline overlay */}
425
  <div className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay z-50" style={{ background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, #fff 2px, #fff 4px)' }}></div>
426
  </div>
427
+
428
+ {/* Layer Toggles */}
429
+ {!isVideo && (heatmapUrl || geometryUrl) && (
430
+ <div className="flex gap-4 items-center justify-center mt-2">
431
+ {heatmapUrl && (
432
+ <button
433
+ onClick={() => setShowGeometryLayer(false)}
434
+ className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all ${!showGeometryLayer ? 'bg-[var(--theme-text)] text-[var(--theme-bg)] shadow-[0_0_15px_rgba(253,232,214,0.3)]' : 'bg-transparent border border-[var(--theme-border)] text-[var(--theme-text)]/70 hover:text-[var(--theme-text)]'}`}
435
+ >
436
+ Noise Heatmap
437
+ </button>
438
+ )}
439
+ {geometryUrl && (
440
+ <button
441
+ onClick={() => setShowGeometryLayer(true)}
442
+ className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all ${showGeometryLayer ? 'bg-[var(--theme-text)] text-[var(--theme-bg)] shadow-[0_0_15px_rgba(253,232,214,0.3)]' : 'bg-transparent border border-[var(--theme-border)] text-[var(--theme-text)]/70 hover:text-[var(--theme-text)]'}`}
443
+ >
444
+ Perspective Geometry
445
+ </button>
446
+ )}
447
+ </div>
448
+ )}
449
  </div>
450
 
451
  {/* Right Side: Analysis */}
 
473
  }`}>
474
  Analysis Verdict
475
  </span>
476
+ <h1 className="text-4xl md:text-6xl font-black tracking-tight text-[var(--theme-text)] my-2">
477
  {label}
478
  </h1>
479
  {confidenceVal !== null && (
 
530
  </div>
531
  </div>
532
 
533
+ {geometryScore !== null && (
534
+ <div className="upload-glass p-6 rounded-2xl flex flex-col gap-4 !cursor-none relative overflow-hidden group">
535
+ <div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-[var(--theme-border)]/30 to-transparent transform -translate-x-full group-hover:translate-x-full transition-transform duration-1000 delay-200"></div>
536
+ <div className="p-2 bg-[var(--theme-text)]/10 w-10 h-10 flex items-center justify-center rounded-lg border border-[var(--theme-border)]">
537
+ <svg className="w-5 h-5 text-[var(--theme-text)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
538
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5" />
539
+ </svg>
540
+ </div>
541
+ <div>
542
+ <h4 className="text-[var(--theme-text)] font-bold text-lg mb-1">Perspective Consistency</h4>
543
+ <p className="text-[#d0c4bb] text-xs leading-relaxed opacity-80 mb-3">Mathematical vanishing-point coherence. Low score means physics-defying structural lines.</p>
544
+ <div className="flex items-end gap-2">
545
+ <span className={`text-2xl font-mono ${geometryScore >= 75 ? 'text-emerald-400/90' : 'text-amber-400/90'}`}>{geometryScore.toFixed(1)}</span>
546
+ <span className="text-[10px] uppercase tracking-widest opacity-40 mb-1 leading-none">/100</span>
547
+ </div>
548
+ </div>
549
+ </div>
550
+ )}
551
+
552
+ <div className={`upload-glass p-6 rounded-2xl flex flex-col gap-4 !cursor-none ${geometryScore !== null ? 'md:col-span-1' : 'md:col-span-2'} relative overflow-hidden group`}>
553
  <div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-[var(--theme-border)]/30 to-transparent transform -translate-x-full group-hover:translate-x-full transition-transform duration-1000 delay-200"></div>
554
  <Hash className="w-6 h-6 text-[var(--theme-text)] opacity-80" />
555
  <div>
frontend/components/upload/UploadZone.tsx CHANGED
@@ -79,8 +79,18 @@ export default function UploadZone({ autoAnalyze = false }: { autoAnalyze?: bool
79
  setUploadedFileId(fileId);
80
 
81
  let polling = true;
82
- const pollInterval = setInterval(async () => {
 
 
 
83
  if (!polling) return;
 
 
 
 
 
 
 
84
  try {
85
  const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
86
  const statusRes = await axios.get(`${baseUrl}/files/${fileId}`, {
@@ -91,7 +101,6 @@ export default function UploadZone({ autoAnalyze = false }: { autoAnalyze?: bool
91
 
92
  if (fileData.status === 'Completed' || fileData.status === 'completed' || fileData.result || fileData.status === 'COMPLETED') {
93
  polling = false;
94
- clearInterval(pollInterval);
95
  clearInterval(fakeProgressInterval);
96
 
97
  setProcessProgress(100);
@@ -123,17 +132,48 @@ export default function UploadZone({ autoAnalyze = false }: { autoAnalyze?: bool
123
  notifAudioRef.current.play().catch(e => console.warn("Audio play failed:", e));
124
  }
125
  }, 400);
 
126
  } else if (fileData.status === 'Failed' || fileData.status === 'error' || fileData.status === 'FAILED') {
127
  polling = false;
128
- clearInterval(pollInterval);
129
  clearInterval(fakeProgressInterval);
130
  setError("Analysis failed on the server.");
131
  setInteractionState('upload');
 
132
  }
133
  } catch (e) {
134
  console.error("Polling error", e);
135
  }
136
- }, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  } catch (err: any) {
139
  clearInterval(fakeProgressInterval);
@@ -158,6 +198,10 @@ export default function UploadZone({ autoAnalyze = false }: { autoAnalyze?: bool
158
  useEffect(() => {
159
  return () => {
160
  if (mediaPreviewUrl) URL.revokeObjectURL(mediaPreviewUrl);
 
 
 
 
161
  };
162
  }, [mediaPreviewUrl]);
163
 
 
79
  setUploadedFileId(fileId);
80
 
81
  let polling = true;
82
+ let pollCount = 0;
83
+ let timeoutId: NodeJS.Timeout;
84
+
85
+ const pollStatus = async () => {
86
  if (!polling) return;
87
+
88
+ // Pause polling if tab is in the background
89
+ if (document.hidden) {
90
+ timeoutId = setTimeout(pollStatus, 2000);
91
+ return;
92
+ }
93
+
94
  try {
95
  const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
96
  const statusRes = await axios.get(`${baseUrl}/files/${fileId}`, {
 
101
 
102
  if (fileData.status === 'Completed' || fileData.status === 'completed' || fileData.result || fileData.status === 'COMPLETED') {
103
  polling = false;
 
104
  clearInterval(fakeProgressInterval);
105
 
106
  setProcessProgress(100);
 
132
  notifAudioRef.current.play().catch(e => console.warn("Audio play failed:", e));
133
  }
134
  }, 400);
135
+ return;
136
  } else if (fileData.status === 'Failed' || fileData.status === 'error' || fileData.status === 'FAILED') {
137
  polling = false;
 
138
  clearInterval(fakeProgressInterval);
139
  setError("Analysis failed on the server.");
140
  setInteractionState('upload');
141
+ return;
142
  }
143
  } catch (e) {
144
  console.error("Polling error", e);
145
  }
146
+
147
+ // Exponential backoff logic
148
+ pollCount++;
149
+ let nextInterval = 2000; // First 15s (approx 7 polls)
150
+ if (pollCount > 20) {
151
+ nextInterval = 10000; // After 45s, poll every 10s
152
+ } else if (pollCount > 7) {
153
+ nextInterval = 5000; // After 15s, poll every 5s
154
+ }
155
+
156
+ if (pollCount > 40) { // Max out around 4 mins
157
+ polling = false;
158
+ clearInterval(fakeProgressInterval);
159
+ setError("Analysis timed out. Please try again later.");
160
+ setInteractionState('upload');
161
+ return;
162
+ }
163
+
164
+ timeoutId = setTimeout(pollStatus, nextInterval);
165
+ };
166
+
167
+ // Start polling
168
+ pollStatus();
169
+
170
+ // Cleanup function for useEffect unmount
171
+ const cleanup = () => {
172
+ polling = false;
173
+ if (timeoutId) clearTimeout(timeoutId);
174
+ clearInterval(fakeProgressInterval);
175
+ };
176
+ (window as any)._currentUploadCleanup = cleanup;
177
 
178
  } catch (err: any) {
179
  clearInterval(fakeProgressInterval);
 
198
  useEffect(() => {
199
  return () => {
200
  if (mediaPreviewUrl) URL.revokeObjectURL(mediaPreviewUrl);
201
+ if ((window as any)._currentUploadCleanup) {
202
+ (window as any)._currentUploadCleanup();
203
+ delete (window as any)._currentUploadCleanup;
204
+ }
205
  };
206
  }, [mediaPreviewUrl]);
207
 
frontend/contexts/AuthContext.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
- import React, { createContext, useContext, useState, useEffect } from 'react';
3
- import { useRouter, usePathname } from 'next/navigation';
 
4
  import { apiLayer } from '@/lib/api';
5
 
6
  type UserData = {
@@ -28,12 +29,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
28
  const [user, setUser] = useState<UserData | null>(null);
29
  const [loading, setLoading] = useState(true);
30
 
 
 
 
 
31
  const router = useRouter();
 
32
 
33
  useEffect(() => {
34
- // Only check if we are not on a public unprotected path unless it's specifically standard.
35
- // Dashboard strictly requires validation.
36
-
37
  setLoading(true);
38
  apiLayer.getCurrentUser()
39
  .then((res) => {
@@ -43,20 +49,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
43
  .catch(() => {
44
  setIsAuthenticated(false);
45
  setUser(null);
46
- // Explicitly guard the dashboard
47
- if (window.location.pathname.includes('/dashboard')) {
48
  router.push('/login');
49
  }
50
  })
51
  .finally(() => {
52
  setLoading(false);
53
  });
54
- }, [router]);
 
 
55
 
56
  const logout = () => {
57
  localStorage.removeItem("access_token");
58
  setIsAuthenticated(false);
59
  setUser(null);
 
60
  router.push("/login");
61
  };
62
 
 
1
  "use client";
2
+ import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
3
+ import { usePathname } from 'next/navigation';
4
+ import { useRouter } from 'next/navigation';
5
  import { apiLayer } from '@/lib/api';
6
 
7
  type UserData = {
 
29
  const [user, setUser] = useState<UserData | null>(null);
30
  const [loading, setLoading] = useState(true);
31
 
32
+ // Guard against double-execution in React StrictMode / Next.js App Router
33
+ // (router from useRouter() gets a new identity on every render, so we NEVER
34
+ // put it in the dep array — we check auth exactly once on mount)
35
+ const hasFetched = useRef(false);
36
  const router = useRouter();
37
+ const pathname = usePathname();
38
 
39
  useEffect(() => {
40
+ if (hasFetched.current) return;
41
+ hasFetched.current = true;
42
+
43
  setLoading(true);
44
  apiLayer.getCurrentUser()
45
  .then((res) => {
 
49
  .catch(() => {
50
  setIsAuthenticated(false);
51
  setUser(null);
52
+ // Only redirect to login from protected routes
53
+ if (pathname?.includes('/dashboard') || pathname?.includes('/profile')) {
54
  router.push('/login');
55
  }
56
  })
57
  .finally(() => {
58
  setLoading(false);
59
  });
60
+ // Empty deps: intentional. Auth is checked once on mount, never on re-render.
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, []);
63
 
64
  const logout = () => {
65
  localStorage.removeItem("access_token");
66
  setIsAuthenticated(false);
67
  setUser(null);
68
+ hasFetched.current = false; // Allow re-check after logout
69
  router.push("/login");
70
  };
71