Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions _headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
1 change: 1 addition & 0 deletions _redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* /index.html 200
5 changes: 5 additions & 0 deletions backend/hf_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ async def _detect_clip_generic(image: Union[Image.Image, bytes], labels: List[st

# --- Specific Detectors ---

async def detect_vandalism_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None):
labels = ["graffiti", "vandalism", "broken window", "defaced property", "clean wall", "intact property"]
targets = ["graffiti", "vandalism", "broken window", "defaced property"]
return await _detect_clip_generic(image, labels, targets, client)

async def detect_illegal_parking_clip(image: Union[Image.Image, bytes], client: httpx.AsyncClient = None):
labels = ["illegal parking", "car blocking driveway", "double parked", "car on sidewalk", "legal parking", "empty street"]
targets = ["illegal parking", "car blocking driveway", "double parked", "car on sidewalk"]
Expand Down
6 changes: 3 additions & 3 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@
from backend.local_ml_service import (
detect_infrastructure_local,
detect_flooding_local,
detect_vandalism_local,
get_detection_status
)
Comment on lines 56 to 60
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backend/tests/test_detection_bytes.py patches backend.main.detect_vandalism_local, but this PR removes detect_vandalism_local from backend.main imports. That test will now error during patching. Either update the test to patch backend.main.detect_vandalism_clip, or re-export an alias in backend.main for backward-compatible mocking.

Copilot uses AI. Check for mistakes.
from backend.gemini_services import get_ai_services, initialize_ai_services
from backend.spatial_utils import find_nearby_issues, get_bounding_box
from backend.hf_api_service import (
detect_vandalism_clip,
detect_illegal_parking_clip,
detect_street_light_clip,
detect_fire_clip,
Expand Down Expand Up @@ -1170,11 +1170,11 @@ async def detect_vandalism_endpoint(request: Request, image: UploadFile = File(.
logger.error(f"Invalid image file for vandalism detection: {e}", exc_info=True)
raise HTTPException(status_code=400, detail="Invalid image file")

# Run detection using unified service (local ML by default)
# Run detection using HF API (Lightweight)
try:
# Use shared HTTP client from app state
client = request.app.state.http_client
detections = await detect_vandalism_local(pil_image, client=client)
detections = await detect_vandalism_clip(pil_image, client=client)
return DetectionResponse(detections=detections)
except Exception as e:
logger.error(f"Vandalism detection error: {e}", exc_info=True)
Expand Down
5 changes: 5 additions & 0 deletions frontend/public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
1 change: 1 addition & 0 deletions frontend/public/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* /index.html 200
1 change: 1 addition & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const DETECTORS = {
accessibility: React.lazy(() => import('./AccessibilityDetector')),
crowd: React.lazy(() => import('./CrowdDetector')),
severity: React.lazy(() => import('./SeverityDetector')),
'civic-eye': React.lazy(() => import('./CivicEyeDetector')),
};

// Valid view paths for navigation safety
Expand Down
266 changes: 162 additions & 104 deletions frontend/src/CivicEyeDetector.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { useRef, useState, useEffect } from 'react';
import { Camera, Eye, Activity, Shield, Sparkles, MapPin, RefreshCw, AlertTriangle } from 'lucide-react';
import { detectorsApi } from './api';
import { useNavigate } from 'react-router-dom';
import { Eye, Shield, Leaf, Building, RefreshCcw, Info, CheckCircle } from 'lucide-react';
import { detectorsApi } from './api/detectors';

const CivicEyeDetector = ({ onBack }) => {
const videoRef = useRef(null);
const canvasRef = useRef(null);
const [stream, setStream] = useState(null);
const [isStreaming, setIsStreaming] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const navigate = useNavigate();

useEffect(() => {
startCamera();
Expand All @@ -18,155 +20,211 @@ const CivicEyeDetector = ({ onBack }) => {
const startCamera = async () => {
setError(null);
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 640 },
height: { ideal: 480 }
}
});
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
videoRef.current.srcObject = stream;
setIsStreaming(true);
}
} catch (err) {
setError("Camera access failed: " + err.message);
setError("Could not access camera: " + err.message);
setIsStreaming(false);
}
};

const stopCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (videoRef.current && videoRef.current.srcObject) {
const tracks = videoRef.current.srcObject.getTracks();
tracks.forEach(track => track.stop());
videoRef.current.srcObject = null;
setIsStreaming(false);
}
};

const analyze = async () => {
const captureAndAnalyze = async () => {
if (!videoRef.current || !canvasRef.current) return;

setAnalyzing(true);
setResult(null);
setError(null);

const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');

canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0);
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}

context.drawImage(video, 0, 0, canvas.width, canvas.height);
Comment on lines +49 to +64
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

captureAndAnalyze() does not verify the video is ready before reading video.videoWidth/videoHeight; if metadata isn't loaded yet, this can capture a blank/0x0 frame. Other detectors in this codebase early-return when video.readyState !== 4 before capturing (e.g., frontend/src/PotholeDetector.jsx:64-66). Add a similar readiness guard (and/or disable the capture button until ready).

Copilot uses AI. Check for mistakes.

canvas.toBlob(async (blob) => {
if (!blob) return;
if (!blob) {
setAnalyzing(false);
return;
}

const formData = new FormData();
formData.append('image', blob, 'civic_eye.jpg');
formData.append('image', blob, 'capture.jpg');

try {
const data = await detectorsApi.civicEye(formData);
if (data.error) throw new Error(data.error);
setResult(data);
stopCamera();
} catch (err) {
console.error(err);
setError("Analysis failed. Please try again.");
console.error("Analysis failed:", err);
setError("Failed to analyze image. Please try again.");
} finally {
setAnalyzing(false);
}
}, 'image/jpeg', 0.8);
}, 'image/jpeg', 0.85);
};

const ScoreCard = ({ title, status, score, icon, color }) => (
<div className={`p-4 rounded-xl border ${color} bg-white shadow-sm flex items-center justify-between`}>
const handleReport = () => {
if (result) {
navigate('/report', {
state: {
category: 'infrastructure',
description: `[Civic Eye Analysis]\nSafety: ${result.safety?.status} (${(result.safety?.score * 100).toFixed(0)}%)\nCleanliness: ${result.cleanliness?.status} (${(result.cleanliness?.score * 100).toFixed(0)}%)\nInfrastructure: ${result.infrastructure?.status} (${(result.infrastructure?.score * 100).toFixed(0)}%)`
}
});
}
};

const resetAnalysis = () => {
setResult(null);
startCamera();
};

const ScoreCard = ({ icon: Icon, label, status, score, color }) => (
<div className="bg-white rounded-xl p-3 shadow-sm border border-gray-100 flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full bg-opacity-10 ${color.replace('border', 'bg').replace('text', 'bg')}`}>
{icon}
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} />
</div>
<div>
<h4 className="text-xs font-bold uppercase text-gray-500">{title}</h4>
<p className="font-bold text-gray-800 capitalize">{status}</p>
<p className="text-xs text-gray-500 font-bold uppercase">{label}</p>
<p className="text-sm font-semibold capitalize text-gray-800">{status}</p>
</div>
</div>
<div className="text-right">
<span className="text-lg font-bold">{(score * 10).toFixed(1)}</span>
<span className="text-xs text-gray-400">/10</span>
<span className="text-lg font-bold text-gray-700">{(score * 100).toFixed(0)}</span>
<span className="text-xs text-gray-400">%</span>
</div>
</div>
);

return (
<div className="flex flex-col items-center w-full max-w-md mx-auto">
{error && (
<div className="w-full bg-red-50 text-red-600 p-3 rounded-lg mb-4 flex items-center gap-2">
<AlertTriangle size={18} />
<span className="text-sm">{error}</span>
</div>
)}

<div className="relative w-full aspect-[4/3] bg-black rounded-2xl overflow-hidden shadow-lg mb-6">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
<canvas ref={canvasRef} className="hidden" />

{analyzing && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-sm">
<div className="flex flex-col items-center text-white">
<Activity className="animate-pulse mb-2" size={32} />
<span className="font-medium">Analyzing scene...</span>
</div>
<div className="flex flex-col h-full bg-gray-50 p-4">
<div className="flex items-center justify-between mb-4">
<button onClick={onBack} className="text-gray-600 font-medium">
&larr; Back
</button>
Comment on lines +125 to +127
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Back button calls onBack, but this component is mounted as <Component /> inside DetectorWrapper (no props passed), so onBack will be undefined and clicking Back will throw at runtime. Either remove this internal header and rely on DetectorWrapper's Back control, or pass an onBack prop from App.jsx (or use navigate('/') directly here).

Copilot uses AI. Check for mistakes.
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-2">
<Eye className="text-blue-600" />
Civic Eye
</h2>
<div className="w-8"></div>
</div>

<div className="flex-grow flex flex-col items-center justify-center">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 w-full max-w-md text-center">
{error}
<button onClick={startCamera} className="block mt-2 text-sm font-bold underline w-full">Retry Camera</button>
</div>
)}
</div>

{result ? (
<div className="w-full space-y-4 animate-fadeIn">
<h3 className="text-xl font-bold text-center mb-2">Civic Report Card</h3>

<ScoreCard
title="Safety"
status={result.safety.status}
score={result.safety.score}
icon={<Shield size={20} className="text-blue-600"/>}
color="border-blue-100"
/>

<ScoreCard
title="Cleanliness"
status={result.cleanliness.status}
score={result.cleanliness.score}
icon={<Sparkles size={20} className="text-green-600"/>}
color="border-green-100"
/>

<ScoreCard
title="Infrastructure"
status={result.infrastructure.status}
score={result.infrastructure.score}
icon={<MapPin size={20} className="text-orange-600"/>}
color="border-orange-100"
/>

<button
onClick={() => setResult(null)}
className="w-full mt-6 bg-gray-900 text-white py-3 rounded-xl font-bold hover:bg-gray-800 transition flex items-center justify-center gap-2"
>
<RefreshCw size={18} />
Analyze New Scene
</button>
<div className="relative w-full max-w-md bg-black rounded-2xl overflow-hidden shadow-xl aspect-[3/4] md:aspect-video mb-6">
{!result ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
<canvas ref={canvasRef} className="hidden" />

<div className="absolute inset-0 border-2 border-white/30 m-8 rounded-lg pointer-events-none"></div>

{isStreaming && !analyzing && (
<div className="absolute bottom-6 left-0 right-0 flex justify-center">
<button
onClick={captureAndAnalyze}
className="bg-white rounded-full p-4 shadow-lg active:scale-95 transition-transform"
>
<div className="w-16 h-16 rounded-full border-4 border-blue-500 bg-blue-50"></div>
</button>
</div>
)}

{analyzing && (
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center backdrop-blur-sm text-white">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mb-4"></div>
<p className="font-medium">Analyzing Scene...</p>
</div>
)}
</>
) : (
<div className="absolute inset-0 bg-gray-50 flex flex-col p-4 overflow-y-auto">
<h3 className="text-lg font-bold text-gray-800 mb-4 text-center">Analysis Results</h3>

<ScoreCard
icon={Shield}
label="Safety"
status={result.safety?.status}
score={result.safety?.score || 0}
color="bg-blue-100 text-blue-600"
/>
<ScoreCard
icon={Leaf}
label="Cleanliness"
status={result.cleanliness?.status}
score={result.cleanliness?.score || 0}
color="bg-green-100 text-green-600"
/>
<ScoreCard
icon={Building}
label="Infrastructure"
status={result.infrastructure?.status}
score={result.infrastructure?.score || 0}
color="bg-orange-100 text-orange-600"
/>

<div className="mt-4 flex gap-3">
<button
onClick={resetAnalysis}
className="flex-1 bg-white border border-gray-300 text-gray-700 py-3 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-gray-50 transition"
>
<RefreshCcw size={20} />
Scan Again
</button>
<button
onClick={handleReport}
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-blue-700 transition shadow-lg shadow-blue-200"
>
<CheckCircle size={20} />
Report
</button>
</div>
</div>
)}
</div>
) : (
<div className="w-full">
<button
onClick={analyze}
disabled={analyzing || !!error}
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 text-white py-4 rounded-xl font-bold shadow-lg hover:shadow-xl transition transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye size={24} />
Analyze Scene
</button>
<p className="text-center text-sm text-gray-500 mt-3">
Get an instant AI assessment of this location

{!result && (
<p className="text-gray-500 text-sm mt-4 text-center px-6">
<Info size={14} className="inline mr-1" />
Scan the area to assess safety, cleanliness, and infrastructure quality.
</p>
</div>
)}
)}
</div>
</div>
);
};
Expand Down
Loading