-
Notifications
You must be signed in to change notification settings - Fork 35
Refactor Vandalism to CLIP and Add Civic Eye UI #315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d9b4fce
221ed96
fa9b164
9e9c34c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| /* /index.html 200 |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| /* /index.html 200 |
| 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(); | ||
|
|
@@ -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
|
||
|
|
||
| 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"> | ||
| ← Back | ||
| </button> | ||
|
Comment on lines
+125
to
+127
|
||
| <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> | ||
| ); | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
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.pypatchesbackend.main.detect_vandalism_local, but this PR removesdetect_vandalism_localfrombackend.mainimports. That test will now error during patching. Either update the test to patchbackend.main.detect_vandalism_clip, or re-export an alias inbackend.mainfor backward-compatible mocking.