|
|
import io |
|
|
import json |
|
|
import logging |
|
|
import os |
|
|
import math |
|
|
from typing import Optional, List |
|
|
from collections import deque |
|
|
from datetime import datetime, timedelta |
|
|
|
|
|
import cv2 |
|
|
import numpy as np |
|
|
import imagehash |
|
|
import uvicorn |
|
|
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, BackgroundTasks |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from PIL import Image, ImageOps |
|
|
from ultralytics import YOLO |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REPORT_HISTORY = deque(maxlen=200) |
|
|
|
|
|
|
|
|
|
|
|
USER_ACTIVITY = {} |
|
|
|
|
|
|
|
|
DEDUP_DISTANCE_METERS = 20.0 |
|
|
DEDUP_TIME_WINDOW_HOURS = 24 |
|
|
SPAM_VELOCITY_LIMIT = 3 |
|
|
SPAM_VELOCITY_WINDOW_SECONDS = 60 |
|
|
|
|
|
|
|
|
import firebase_admin |
|
|
from firebase_admin import credentials, db |
|
|
|
|
|
try: |
|
|
if "FIREBASE_CREDENTIALS" in os.environ: |
|
|
cred_json = json.loads(os.environ["FIREBASE_CREDENTIALS"]) |
|
|
cred = credentials.Certificate(cred_json) |
|
|
firebase_admin.initialize_app(cred, { |
|
|
'databaseURL': os.environ.get("FIREBASE_DB_URL", "https://arise-3aaac-default-rtdb.firebaseio.com/") |
|
|
}) |
|
|
logger.info("Firebase Admin Initialized successfully.") |
|
|
else: |
|
|
logger.warning("FIREBASE_CREDENTIALS env var not found. Firebase features will be disabled.") |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to init Firebase: {e}") |
|
|
|
|
|
|
|
|
from sentence_transformers import SentenceTransformer, util |
|
|
logger.info("Loading CLIP model (sentence-transformers/clip-ViT-B-32)...") |
|
|
try: |
|
|
|
|
|
embedding_model = SentenceTransformer('sentence-transformers/clip-ViT-B-32') |
|
|
logger.info("CLIP model loaded.") |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to load CLIP model: {e}") |
|
|
embedding_model = None |
|
|
|
|
|
app = FastAPI(title="Arise AI API", version="1.0.0") |
|
|
|
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
|
|
|
try: |
|
|
model = YOLO("best.pt") |
|
|
logger.info("YOLO model loaded successfully.") |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to load model: {e}") |
|
|
model = None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def haversine_distance(lat1, lon1, lat2, lon2): |
|
|
"""Calculate the great circle distance in meters between two points.""" |
|
|
R = 6371000 |
|
|
phi1, phi2 = math.radians(lat1), math.radians(lat2) |
|
|
dphi = math.radians(lat2 - lat1) |
|
|
dlambda = math.radians(lon2 - lon1) |
|
|
|
|
|
a = math.sin(dphi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2)**2 |
|
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) |
|
|
return R * c |
|
|
|
|
|
|
|
|
def fetch_firebase_history(): |
|
|
""" |
|
|
Fetches reports from Firebase Realtime Database to populate local history. |
|
|
This ensures we have the latest data for deduplication even if server restarted. |
|
|
""" |
|
|
global REPORT_HISTORY |
|
|
try: |
|
|
if not firebase_admin._apps: |
|
|
return |
|
|
|
|
|
|
|
|
ref = db.reference('reports') |
|
|
|
|
|
snapshot = ref.order_by_child('timestamp').limit_to_last(100).get() |
|
|
|
|
|
if not snapshot: |
|
|
return |
|
|
|
|
|
new_history = deque(maxlen=200) |
|
|
|
|
|
for key, val in snapshot.items(): |
|
|
try: |
|
|
|
|
|
r_lat = float(val.get('latitude', 0)) |
|
|
r_lon = float(val.get('longitude', 0)) |
|
|
r_issue = val.get('category', 'General') |
|
|
r_user = val.get('userId', 'unknown') |
|
|
|
|
|
|
|
|
t_str = val.get('timestamp') |
|
|
dt = datetime.now() |
|
|
if isinstance(t_str, int): |
|
|
dt = datetime.fromtimestamp(t_str / 1000.0) |
|
|
elif isinstance(t_str, str): |
|
|
try: |
|
|
dt = datetime.fromisoformat(t_str.replace('Z', '+00:00')) |
|
|
except: pass |
|
|
|
|
|
|
|
|
r_hash = val.get('imageHash', "") |
|
|
|
|
|
|
|
|
new_history.append({ |
|
|
'lat': r_lat, |
|
|
'lon': r_lon, |
|
|
'issue': r_issue, |
|
|
'time': dt, |
|
|
'user': r_user, |
|
|
'hash': r_hash |
|
|
}) |
|
|
except Exception as e: |
|
|
continue |
|
|
|
|
|
|
|
|
|
|
|
REPORT_HISTORY = new_history |
|
|
logger.info(f"Refreshed history from Firebase: {len(REPORT_HISTORY)} items") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Failed to fetch from Firebase: {e}") |
|
|
|
|
|
|
|
|
def check_spam_status(user_email, current_time, blur_score): |
|
|
is_spam = False |
|
|
reasons = [] |
|
|
|
|
|
|
|
|
if blur_score < 100.0: |
|
|
is_spam = True |
|
|
reasons.append(f"Image too blurry (Score: {int(blur_score)})") |
|
|
|
|
|
if not user_email: |
|
|
return is_spam, reasons |
|
|
|
|
|
|
|
|
if user_email not in USER_ACTIVITY: |
|
|
USER_ACTIVITY[user_email] = deque(maxlen=10) |
|
|
|
|
|
timestamps = USER_ACTIVITY[user_email] |
|
|
timestamps.append(current_time) |
|
|
|
|
|
|
|
|
recent_activity = [t for t in timestamps if (current_time - t).total_seconds() <= SPAM_VELOCITY_WINDOW_SECONDS] |
|
|
|
|
|
if len(recent_activity) > SPAM_VELOCITY_LIMIT: |
|
|
is_spam = True |
|
|
reasons.append("Submission rate exceeded limit") |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
if firebase_admin._apps: |
|
|
|
|
|
|
|
|
pass |
|
|
except Exception as e: |
|
|
logger.error(f"Firebase Check Failed: {e}") |
|
|
|
|
|
return is_spam, reasons |
|
|
|
|
|
|
|
|
def check_hybrid_duplicate(lat, lon, issue_type, current_time, pil_image, description): |
|
|
""" |
|
|
Checks for duplicates in layers: |
|
|
1. Spatial: Is it nearby? (< 20m) AND recent (< 24h) |
|
|
2. Category: Is it the same type of issue? (Loose match) |
|
|
3. Visual (CLIP): Do the images look the same? (Cosine Sim > 0.9) |
|
|
4. Semantic (CLIP): Is the description describing the same thing? (Cosine Sim > 0.85) |
|
|
""" |
|
|
if lat == 0 or lon == 0: |
|
|
return False, None |
|
|
|
|
|
issue_lower = issue_type.lower() |
|
|
|
|
|
|
|
|
img_emb = None |
|
|
txt_emb = None |
|
|
if embedding_model: |
|
|
try: |
|
|
img_emb = embedding_model.encode(pil_image, convert_to_tensor=True) |
|
|
if description: |
|
|
txt_emb = embedding_model.encode(description, convert_to_tensor=True) |
|
|
except Exception as e: |
|
|
logger.error(f"Embedding generation failed: {e}") |
|
|
|
|
|
for report in REPORT_HISTORY: |
|
|
|
|
|
if (current_time - report['time']) > timedelta(hours=DEDUP_TIME_WINDOW_HOURS): |
|
|
continue |
|
|
|
|
|
|
|
|
dist = haversine_distance(lat, lon, report['lat'], report['lon']) |
|
|
if dist > DEDUP_DISTANCE_METERS: |
|
|
continue |
|
|
|
|
|
|
|
|
report_issue_lower = report['issue'].lower() |
|
|
keywords = ["garbage", "pothole", "accident", "water", "streetlight"] |
|
|
cat_match = False |
|
|
if issue_lower == report_issue_lower: cat_match = True |
|
|
else: |
|
|
for kw in keywords: |
|
|
if kw in issue_lower and kw in report_issue_lower: |
|
|
cat_match = True |
|
|
break |
|
|
|
|
|
|
|
|
if not cat_match and dist > 5: |
|
|
continue |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if img_emb is not None and 'visual_emb' in report: |
|
|
hist_img_emb = report['visual_emb'] |
|
|
if hist_img_emb is not None: |
|
|
sim = util.cos_sim(img_emb, hist_img_emb) |
|
|
if sim > 0.9: |
|
|
return True, f"Visual Duplicate found {int(dist)}m away (Sim: {sim.item():.2f})" |
|
|
|
|
|
|
|
|
return True, f"Similar report found {int(dist)}m away" |
|
|
|
|
|
return False, None |
|
|
|
|
|
return False, None |
|
|
|
|
|
|
|
|
from pydantic import BaseModel |
|
|
|
|
|
class HistoryItem(BaseModel): |
|
|
lat: float |
|
|
lon: float |
|
|
issue: str |
|
|
time: float |
|
|
user: str |
|
|
hash: Optional[str] = None |
|
|
|
|
|
class EmailRequest(BaseModel): |
|
|
to_email: str |
|
|
subject: str |
|
|
body: str |
|
|
|
|
|
class NotifyStatusRequest(BaseModel): |
|
|
status: str |
|
|
user_email: str |
|
|
issue_type: str |
|
|
severity: Optional[str] = "Medium" |
|
|
department: Optional[str] = "General" |
|
|
location: Optional[str] = "" |
|
|
imageUrl: Optional[str] = None |
|
|
|
|
|
|
|
|
import smtplib |
|
|
import requests |
|
|
from email.mime.text import MIMEText |
|
|
from email.mime.multipart import MIMEMultipart |
|
|
|
|
|
|
|
|
def generate_html_email(title: str, status: str, details: dict, image_url: str = None): |
|
|
""" |
|
|
Generates a world-class, responsive HTML email for Arise. |
|
|
""" |
|
|
|
|
|
colors = { |
|
|
"primary": "#0f172a", |
|
|
"accent": "#3b82f6", |
|
|
"bg": "#f8fafc", |
|
|
"card": "#ffffff", |
|
|
"text": "#334155", |
|
|
"sub": "#64748b", |
|
|
"border": "#e2e8f0" |
|
|
} |
|
|
|
|
|
|
|
|
status_config = { |
|
|
"approved": {"color": "#16a34a", "bg": "#dcfce7", "text": "#15803d"}, |
|
|
"resolved": {"color": "#16a34a", "bg": "#dcfce7", "text": "#15803d"}, |
|
|
"rejected": {"color": "#dc2626", "bg": "#fee2e2", "text": "#b91c1c"}, |
|
|
"inprogress": {"color": "#2563eb", "bg": "#dbeafe", "text": "#1e40af"}, |
|
|
"pending": {"color": "#d97706", "bg": "#fef3c7", "text": "#b45309"}, |
|
|
"auto-approved": {"color": "#7c3aed", "bg": "#ede9fe", "text": "#6d28d9"} |
|
|
} |
|
|
|
|
|
st_key = status.lower().replace(" ", "").replace("-", "") |
|
|
st = status_config.get(st_key, status_config["pending"]) |
|
|
|
|
|
|
|
|
rows_html = "" |
|
|
for k, v in details.items(): |
|
|
rows_html += f""" |
|
|
<tr> |
|
|
<td style="padding: 16px 0; border-bottom: 1px solid {colors['border']}; color: {colors['sub']}; font-size: 14px; width: 40%; vertical-align: top;"> |
|
|
{k} |
|
|
</td> |
|
|
<td style="padding: 16px 0 16px 16px; border-bottom: 1px solid {colors['border']}; color: {colors['primary']}; font-weight: 600; font-size: 14px; vertical-align: top; text-align: right;"> |
|
|
{v} |
|
|
</td> |
|
|
</tr> |
|
|
""" |
|
|
|
|
|
|
|
|
image_section = "" |
|
|
if image_url: |
|
|
image_section = f""" |
|
|
<div style="margin: 32px 0; text-align: center;"> |
|
|
<div style="border: 1px solid {colors['border']}; padding: 8px; border-radius: 12px; display: inline-block; background: #fff;"> |
|
|
<img src="{image_url}" alt="Report Evidence" style="display: block; max-width: 100%; width: 500px; height: auto; border-radius: 8px;"> |
|
|
</div> |
|
|
<p style="margin: 8px 0 0; color: {colors['sub']}; font-size: 12px;">Captured Evidence</p> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
return f""" |
|
|
<!DOCTYPE html> |
|
|
<html> |
|
|
<head> |
|
|
<meta charset="utf-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>{title}</title> |
|
|
</head> |
|
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: {colors['bg']}; -webkit-font-smoothing: antialiased;"> |
|
|
|
|
|
<!-- Main Container --> |
|
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color: {colors['bg']}; padding: 20px 0;"> |
|
|
<tr> |
|
|
<td align="center"> |
|
|
|
|
|
<!-- Content Card --> |
|
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color: {colors['card']}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); overflow: hidden; max-width: 95%;"> |
|
|
|
|
|
<!-- Header Image / Accent --> |
|
|
<tr> |
|
|
<td style="background-color: {colors['primary']}; padding: 30px; text-align: center;"> |
|
|
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #ffffff; letter-spacing: 0.5px;">{title}</h1> |
|
|
<p style="margin: 5px 0 0; color: #94a3b8; font-size: 14px;">Civic Issue Report Update</p> |
|
|
</td> |
|
|
</tr> |
|
|
|
|
|
<!-- Body --> |
|
|
<tr> |
|
|
<td style="padding: 30px;"> |
|
|
|
|
|
<!-- Status Badge --> |
|
|
<div style="text-align: center; margin-bottom: 30px;"> |
|
|
<span style="background-color: {st['bg']}; color: {st['text']}; padding: 8px 16px; border-radius: 50px; font-weight: 700; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;"> |
|
|
{status} |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
<p style="font-size: 16px; line-height: 1.6; color: {colors['text']}; margin-bottom: 24px;"> |
|
|
Hello, <br> |
|
|
This is an automated update regarding your civic report. The current status has been changed. |
|
|
</p> |
|
|
|
|
|
<!-- Details Table --> |
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 30px; border: 1px solid {colors['border']}; border-radius: 8px; overflow: hidden;"> |
|
|
{rows_html} |
|
|
</table> |
|
|
|
|
|
{image_section} |
|
|
|
|
|
<!-- Footer Message --> |
|
|
<div style="border-top: 1px solid {colors['border']}; margin-top: 30px; padding-top: 20px; text-align: center;"> |
|
|
<p style="margin: 0; color: {colors['sub']}; font-size: 14px;"> |
|
|
Thank you for being a responsible citizen. <br> |
|
|
<strong style="color: {colors['primary']};">Team Arise</strong> |
|
|
</p> |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
|
|
|
<!-- Footer --> |
|
|
<tr> |
|
|
<td style="background-color: {colors['bg']}; padding: 15px; text-align: center; font-size: 12px; color: {colors['sub']};"> |
|
|
© {datetime.now().year} Arise Civic AI. <br> |
|
|
<a href="#" style="color: {colors['accent']}; text-decoration: none;">View on Dashboard</a> |
|
|
</td> |
|
|
</tr> |
|
|
|
|
|
</table> |
|
|
</td> |
|
|
</tr> |
|
|
</table> |
|
|
|
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
BREVO_API_KEY = "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" |
|
|
|
|
|
def send_email_via_brevo(to_email: str, subject: str, body: str, is_html: bool = True, attachment_base64: str = None): |
|
|
"""Sends email via Brevo HTTP API (Bypasses SMTP ports). Includes optional Base64 attachment with CID support.""" |
|
|
if not BREVO_API_KEY or "xkeysib" not in BREVO_API_KEY: |
|
|
logger.warning("BREVO_API_KEY not set or invalid. Skipping HTTP email.") |
|
|
return False |
|
|
|
|
|
url = "https://api.brevo.com/v3/smtp/email" |
|
|
headers = { |
|
|
"accept": "application/json", |
|
|
"api-key": BREVO_API_KEY, |
|
|
"content-type": "application/json" |
|
|
} |
|
|
|
|
|
payload = { |
|
|
"sender": {"name": "Arise Civic AI", "email": "shivarajmani2005@gmail.com"}, |
|
|
"to": [{"email": to_email}], |
|
|
"subject": subject, |
|
|
"htmlContent": body if is_html else f"<p>{body}</p>" |
|
|
} |
|
|
|
|
|
if attachment_base64: |
|
|
|
|
|
|
|
|
payload["attachment"] = [{ |
|
|
"content": attachment_base64, |
|
|
"name": "report_image.jpg", |
|
|
"contentId": "report_image" |
|
|
}] |
|
|
|
|
|
|
|
|
try: |
|
|
response = requests.post(url, json=payload, headers=headers, timeout=10) |
|
|
if response.status_code in [200, 201, 202]: |
|
|
logger.info(f"Brevo HTTP Email sent to {to_email}") |
|
|
return True |
|
|
else: |
|
|
logger.error(f"Brevo Error {response.status_code}: {response.text}") |
|
|
return False |
|
|
except Exception as e: |
|
|
logger.error(f"Brevo Request Failed: {e}") |
|
|
return False |
|
|
|
|
|
def send_email_helper(to_email: str, subject: str, body: str, is_html: bool = False, attachment_base64: str = None): |
|
|
"""Sends an email using Brevo (HTTP) first, falls back to Gmail SMTP.""" |
|
|
|
|
|
|
|
|
if send_email_via_brevo(to_email, subject, body, is_html, attachment_base64): |
|
|
return True |
|
|
|
|
|
|
|
|
logger.info("Falling back to SMTP...") |
|
|
sender_email = "shivarajmani2005@gmail.com" |
|
|
|
|
|
|
|
|
|
|
|
GMAIL_APP_PASSWORD_DIRECT = "snja ljzu xbzd fdvl" |
|
|
|
|
|
app_password = GMAIL_APP_PASSWORD_DIRECT if "abcd" not in GMAIL_APP_PASSWORD_DIRECT else os.environ.get("GMAIL_APP_PASSWORD") |
|
|
|
|
|
if not app_password or "abcd" in app_password: |
|
|
logger.warning("GMAIL_APP_PASSWORD not set. Email not sent.") |
|
|
return False |
|
|
|
|
|
try: |
|
|
msg = MIMEMultipart('alternative') if is_html else MIMEMultipart() |
|
|
msg['From'] = sender_email |
|
|
msg['To'] = to_email |
|
|
msg['Subject'] = subject |
|
|
|
|
|
if is_html: |
|
|
msg.attach(MIMEText(body, 'html')) |
|
|
else: |
|
|
msg.attach(MIMEText(body, 'plain')) |
|
|
|
|
|
server = smtplib.SMTP('smtp.gmail.com', 587) |
|
|
server.starttls() |
|
|
server.login(sender_email, app_password) |
|
|
server.send_message(msg) |
|
|
server.quit() |
|
|
logger.info(f"Email sent to {to_email}") |
|
|
return True |
|
|
except OSError as e: |
|
|
if e.errno == 101: |
|
|
logger.error(f"EMAIL FAILED: Network unreachable (Errno 101). This usually means the hosting platform (e.g. Hugging Face Free Tier) blocks outbound SMTP ports (587). Please use a paid tier or an HTTP-based email API.") |
|
|
else: |
|
|
logger.error(f"Failed to send email (OSError): {e}") |
|
|
return False |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to send email: {e}") |
|
|
return False |
|
|
|
|
|
@app.post("/send-email") |
|
|
async def send_email_endpoint(req: EmailRequest, background_tasks: BackgroundTasks): |
|
|
background_tasks.add_task(send_email_helper, req.to_email, req.subject, req.body) |
|
|
return {"status": "success", "message": "Email queued"} |
|
|
|
|
|
@app.post("/notify-status") |
|
|
async def notify_status_endpoint(req: NotifyStatusRequest, background_tasks: BackgroundTasks): |
|
|
""" |
|
|
Sends a rich HTML email notification with image support for status updates. |
|
|
""" |
|
|
subject = f"Arise Update: {req.issue_type} - {req.status.capitalize()}" |
|
|
|
|
|
details = { |
|
|
"Issue Type": req.issue_type, |
|
|
"Department": req.department, |
|
|
"Severity": req.severity, |
|
|
"Location": req.location or "Provided", |
|
|
"Status Description": f"Your report has been marked as {req.status}. We will keep you updated." |
|
|
} |
|
|
|
|
|
html_body = generate_html_email( |
|
|
"Civic Report Update", |
|
|
req.status, |
|
|
details, |
|
|
req.imageUrl |
|
|
) |
|
|
|
|
|
cid_url = None |
|
|
processed_image = None |
|
|
|
|
|
|
|
|
if req.imageUrl: |
|
|
if req.imageUrl.startswith("data:image"): |
|
|
|
|
|
try: |
|
|
|
|
|
header, encoded = req.imageUrl.split(",", 1) |
|
|
processed_image = encoded |
|
|
cid_url = "cid:report_image" |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to process base64 image in notify: {e}") |
|
|
cid_url = None |
|
|
else: |
|
|
|
|
|
cid_url = req.imageUrl |
|
|
|
|
|
html_body = generate_html_email( |
|
|
"Civic Report Update", |
|
|
req.status, |
|
|
details, |
|
|
cid_url |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
background_tasks.add_task(send_email_helper, req.user_email, subject, html_body, True, processed_image) |
|
|
return {"status": "success", "message": "Notification queued"} |
|
|
|
|
|
@app.post("/sync-history") |
|
|
async def sync_history(items: List[HistoryItem]): |
|
|
""" |
|
|
Syncs recent history from the frontend (Firebase) to the backend. |
|
|
This allows the backend to perform deduplication and spam checks against |
|
|
data that persists across backend restarts. |
|
|
""" |
|
|
count = 0 |
|
|
for item in items: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dt = datetime.fromtimestamp(item.time / 1000.0) |
|
|
|
|
|
|
|
|
if any(r['user'] == item.user and abs((r['time'] - dt).total_seconds()) < 1.0 for r in REPORT_HISTORY): |
|
|
continue |
|
|
|
|
|
REPORT_HISTORY.append({ |
|
|
'lat': item.lat, |
|
|
'lon': item.lon, |
|
|
'issue': item.issue, |
|
|
'time': dt, |
|
|
'user': item.user, |
|
|
'hash': item.hash or "" |
|
|
}) |
|
|
|
|
|
|
|
|
if item.user: |
|
|
if item.user not in USER_ACTIVITY: |
|
|
USER_ACTIVITY[item.user] = deque(maxlen=10) |
|
|
USER_ACTIVITY[item.user].append(dt) |
|
|
|
|
|
count += 1 |
|
|
|
|
|
logger.info(f"Synced {count} distinct history items from frontend.") |
|
|
return {"status": "success", "synced": count} |
|
|
|
|
|
@app.post("/analyze") |
|
|
async def analyze_endpoint( |
|
|
background_tasks: BackgroundTasks, |
|
|
image: UploadFile = File(...), |
|
|
description: str = Form(""), |
|
|
latitude: str = Form("0"), |
|
|
longitude: str = Form("0"), |
|
|
timestamp: str = Form(""), |
|
|
user_email: str = Form(None) |
|
|
): |
|
|
try: |
|
|
|
|
|
try: |
|
|
lat = float(latitude) |
|
|
lon = float(longitude) |
|
|
except ValueError: |
|
|
lat, lon = 0.0, 0.0 |
|
|
|
|
|
current_time = datetime.now() |
|
|
|
|
|
|
|
|
|
|
|
fetch_firebase_history() |
|
|
|
|
|
|
|
|
contents = await image.read() |
|
|
pil_image = Image.open(io.BytesIO(contents)).convert("RGB") |
|
|
|
|
|
|
|
|
try: |
|
|
pil_image = ImageOps.exif_transpose(pil_image) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
img_np = np.array(pil_image) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) |
|
|
blur_score = cv2.Laplacian(gray, cv2.CV_64F).var() |
|
|
is_blur_spam = bool(blur_score < 100.0) |
|
|
|
|
|
|
|
|
is_spam, spam_reasons = check_spam_status(user_email, current_time, blur_score) |
|
|
spam_reason_str = ", ".join(spam_reasons) if spam_reasons else None |
|
|
|
|
|
|
|
|
logger.info("Running YOLO inference...") |
|
|
results = model(img_np, conf=0.1) |
|
|
|
|
|
detections = [] |
|
|
primary_issue = "Unknown" |
|
|
max_conf = 0.0 |
|
|
|
|
|
result = results[0] |
|
|
|
|
|
|
|
|
if len(result.boxes) > 0: |
|
|
for box in result.boxes: |
|
|
cls_id = int(box.cls) |
|
|
conf = float(box.conf) |
|
|
label = model.names[cls_id] |
|
|
detections.append({"class": label, "confidence": conf}) |
|
|
if conf > max_conf: |
|
|
max_conf = conf |
|
|
primary_issue = label |
|
|
|
|
|
|
|
|
if primary_issue == "Unknown" and description: |
|
|
|
|
|
pass |
|
|
|
|
|
desc_lower = description.lower() |
|
|
keywords = {"pothole": "Pothole", "garbage": "Garbage", "accident": "Accident", "streetlight": "Streetlight", "water": "Water"} |
|
|
|
|
|
for k, v in keywords.items(): |
|
|
if k in desc_lower: |
|
|
primary_issue = v |
|
|
max_conf = 0.5 |
|
|
break |
|
|
|
|
|
|
|
|
is_duplicate, dup_reason = check_hybrid_duplicate(lat, lon, primary_issue, current_time, pil_image, description) |
|
|
|
|
|
|
|
|
current_hash = imagehash.phash(pil_image) |
|
|
phash_str = str(current_hash) |
|
|
|
|
|
|
|
|
current_img_emb = None |
|
|
if embedding_model: |
|
|
try: |
|
|
current_img_emb = embedding_model.encode(pil_image, convert_to_tensor=True) |
|
|
except: pass |
|
|
|
|
|
|
|
|
REPORT_HISTORY.append({ |
|
|
'lat': lat, |
|
|
'lon': lon, |
|
|
'issue': primary_issue, |
|
|
'time': current_time, |
|
|
'user': user_email, |
|
|
'hash': phash_str, |
|
|
'visual_emb': current_img_emb |
|
|
}) |
|
|
|
|
|
|
|
|
annotated_frame = result.plot(line_width=2, font_size=1.0) |
|
|
is_success, buffer = cv2.imencode(".jpg", cv2.cvtColor(annotated_frame, cv2.COLOR_RGB2BGR)) |
|
|
processed_image_base64 = None |
|
|
if is_success: |
|
|
import base64 |
|
|
processed_image_base64 = base64.b64encode(buffer).decode("utf-8") |
|
|
|
|
|
|
|
|
category_map = { |
|
|
"pothole": "BBMP - Road Infrastructure", |
|
|
"garbage": "BBMP - Solid Waste Management", |
|
|
"streetlight": "BESCOM - Street Lights", |
|
|
"accident": "Traffic Police / Emergency", |
|
|
"drainagen": "BWSSB - Water & Sewerage", |
|
|
"water": "BWSSB - Water Supply" |
|
|
} |
|
|
|
|
|
department = category_map.get(primary_issue.lower(), "General") |
|
|
|
|
|
|
|
|
severity = "Medium" |
|
|
if primary_issue.lower() in ["accident", "pothole"]: |
|
|
severity = "High" if max_conf > 0.7 else "Medium" |
|
|
elif primary_issue.lower() == "garbage": |
|
|
severity = "Low" if max_conf < 0.5 else "Medium" |
|
|
|
|
|
|
|
|
summary_lines = [] |
|
|
|
|
|
|
|
|
if primary_issue != "Unknown": |
|
|
summary_lines.append(f"Identification: AI detected {primary_issue} with {int(max_conf*100)}% confidence.") |
|
|
else: |
|
|
summary_lines.append("Identification: No specific civic issue could be confidently identified.") |
|
|
|
|
|
|
|
|
if is_blur_spam: |
|
|
summary_lines.append(f"Image Quality: Poor/Blurry (Score: {int(blur_score)}). Please retake.") |
|
|
else: |
|
|
summary_lines.append(f"Image Quality: Good clarity (Score: {int(blur_score)}).") |
|
|
|
|
|
|
|
|
summary_lines.append(f"Assessment: Rated as {severity} severity, routed to {department}.") |
|
|
|
|
|
|
|
|
status_parts = [] |
|
|
if is_duplicate: |
|
|
status_parts.append(f"Duplicate: {dup_reason}.") |
|
|
if is_spam: |
|
|
status_parts.append(f"Spam Flag: {spam_reason_str}.") |
|
|
|
|
|
if not status_parts: |
|
|
status_parts.append("Status: Verified as a unique, valid report.") |
|
|
|
|
|
summary_lines.append(" ".join(status_parts)) |
|
|
|
|
|
ai_summary = "\n".join(summary_lines) |
|
|
|
|
|
|
|
|
|
|
|
auto_status = "pending" |
|
|
if not is_spam and not is_duplicate and primary_issue != "Unknown": |
|
|
if max_conf > 0.8: |
|
|
|
|
|
auto_status = "inProgress" |
|
|
subject = f"Auto-Approved: High Confidence {primary_issue}" |
|
|
|
|
|
details = { |
|
|
"Issue Type": primary_issue, |
|
|
"Confidence": f"{int(max_conf*100)}%", |
|
|
"Department": department, |
|
|
"Location": f"{lat}, {lon}", |
|
|
"Action": "Auto-Approved by AI" |
|
|
} |
|
|
|
|
|
|
|
|
html_body = generate_html_email("New Priority Report", "Auto-Approved", details, "cid:report_image") |
|
|
|
|
|
logger.info("Queuing email to admin (Auto-Approve)...") |
|
|
background_tasks.add_task(send_email_helper, "rockybai8234@gmail.com", subject, html_body, True, processed_image_base64) |
|
|
else: |
|
|
|
|
|
subject = f"Pending Review: {primary_issue}" |
|
|
|
|
|
details = { |
|
|
"Issue Type": primary_issue, |
|
|
"Confidence": f"{int(max_conf*100)}%", |
|
|
"Department": department, |
|
|
"Location": f"{lat}, {lon}", |
|
|
"Action": "Waiting for Admin Verification" |
|
|
} |
|
|
|
|
|
|
|
|
html_body = generate_html_email("New Report Received", "Pending Review", details, "cid:report_image") |
|
|
|
|
|
logger.info("Queuing email to admin (Pending Review)...") |
|
|
background_tasks.add_task(send_email_helper, "shivarajmani2005@gmail.com", subject, html_body, True, processed_image_base64) |
|
|
|
|
|
response_data = { |
|
|
"status": "Success", |
|
|
"suggested_status": auto_status, |
|
|
"issue_type": primary_issue, |
|
|
"civic_confidence": max_conf, |
|
|
"severity": severity, |
|
|
"department": department, |
|
|
"ai_summary": ai_summary, |
|
|
"detections": detections, |
|
|
"processed_image": processed_image_base64, |
|
|
"resolution_estimation": { |
|
|
"estimated_hours": 24 if severity == "High" else 48 |
|
|
}, |
|
|
"spam_analysis": { |
|
|
"is_spam": is_spam, |
|
|
"spam_score": round(blur_score, 2), |
|
|
"reason": spam_reason_str |
|
|
}, |
|
|
"deduplication": { |
|
|
"is_duplicate": is_duplicate, |
|
|
"image_hash": phash_str, |
|
|
"method": "hybrid" |
|
|
}, |
|
|
"steganography": { |
|
|
"has_hidden_data": False, |
|
|
"method": None |
|
|
}, |
|
|
"transmission_analysis": { |
|
|
"status": "success", |
|
|
"binary_decoded": True, |
|
|
"integrity_check": "passed" |
|
|
} |
|
|
} |
|
|
|
|
|
logger.info(f"Analysis complete: {primary_issue}. Duplicate: {is_duplicate}. Spam: {is_spam}") |
|
|
return response_data |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Error analyzing image: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
uvicorn.run(app, host="0.0.0.0", port=7860) |