1199 lines
46 KiB
TypeScript
1199 lines
46 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useRef, useState, useCallback } from "react"
|
||
import dynamic from "next/dynamic"
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Target, AlertTriangle, Film, Activity, Gauge, Monitor, SignpostBig, Map as MapIcon } from "lucide-react"
|
||
|
||
// Dynamically import MapModal with SSR disabled (Leaflet requires window object)
|
||
const MapModal = dynamic(() => import("@/components/map-modal"), { ssr: false })
|
||
|
||
import { DetectionData, DetectionType } from "@/lib/types"
|
||
|
||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||
|
||
|
||
type VideoPlayerSectionProps = {
|
||
data: DetectionData
|
||
videoId: string
|
||
videoFile: File | null // null when loading from server (results page)
|
||
detectionType: DetectionType
|
||
projectId?: string // Optional project ID for fetching detailed summary
|
||
}
|
||
|
||
type DetectionLog = {
|
||
frame: number
|
||
detections: Array<{
|
||
id: number
|
||
type?: string // For signboards
|
||
bbox: { x1: number; y1: number; x2: number; y2: number }
|
||
confidence: number
|
||
latitude?: number
|
||
longitude?: number
|
||
}>
|
||
videoTime: string // Video timestamp (MM:SS)
|
||
}
|
||
|
||
type LocationSummaryData = {
|
||
project: {
|
||
id: string
|
||
name: string
|
||
corridor_name: string | null
|
||
state: string | null
|
||
}
|
||
packages: {
|
||
[packageName: string]: {
|
||
package_id: string
|
||
region: string | null
|
||
locations: {
|
||
[locationName: string]: {
|
||
location_id: string
|
||
chainage: string | null
|
||
detection_count: number
|
||
detections: Array<{
|
||
id: number
|
||
video_id: string
|
||
type: string
|
||
class: string
|
||
confidence: number
|
||
latitude: number
|
||
longitude: number
|
||
frame_number: number
|
||
timestamp_ms: number
|
||
bounding_box: {
|
||
x1: number
|
||
y1: number
|
||
x2: number
|
||
y2: number
|
||
}
|
||
}>
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function DetailedSummarySection({
|
||
projectId,
|
||
videoId,
|
||
show,
|
||
detectionType
|
||
}: {
|
||
projectId: string
|
||
videoId: string
|
||
show: boolean
|
||
detectionType: DetectionType
|
||
}) {
|
||
const [summaryData, setSummaryData] = useState<LocationSummaryData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [showMap, setShowMap] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!show || !videoId || !projectId) return
|
||
|
||
const fetchSummary = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const response = await fetch(`${API_URL}/summary/projects/${projectId}?video_id=${videoId}`, {
|
||
headers: { "ngrok-skip-browser-warning": "true" }
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setSummaryData(data)
|
||
} else {
|
||
console.error(`Failed to fetch summary: ${response.status}`)
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to fetch summary:", err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
fetchSummary()
|
||
}, [show, videoId, projectId])
|
||
|
||
if (!show || loading || !summaryData) return null
|
||
|
||
const isCombined = detectionType === "pot-sign-detection"
|
||
const isPothole = detectionType === "pothole-detection" || isCombined
|
||
|
||
// Flatten all detections for the scrollable list
|
||
const allDetections: Array<{
|
||
detection: any
|
||
locationName: string
|
||
packageName: string
|
||
}> = []
|
||
|
||
Object.entries(summaryData.packages).forEach(([packageName, packageData]) => {
|
||
Object.entries(packageData.locations).forEach(([locationName, locationData]) => {
|
||
locationData.detections.forEach(detection => {
|
||
allDetections.push({ detection, locationName, packageName })
|
||
})
|
||
})
|
||
})
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-2">
|
||
<Card className="bg-white/60 backdrop-blur-md border border-gray-100 rounded-xl overflow-hidden flex flex-col lg:col-span-2">
|
||
<CardHeader className="pb-3 ">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-xl bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
|
||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" className="min-h-8 min-w-8 text-white relative z-10">
|
||
<path d="M50 20L85 80H15L50 20Z" stroke="currentColor" strokeWidth="6" strokeLinejoin="round" />
|
||
<path d="M40 80L50 55L60 80" stroke="currentColor" strokeWidth="6" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
|
||
Detection Locations
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
{isCombined ? "Potholes & Signboards" : isPothole ? "Potholes" : "Signboards"} detected across project locations
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowMap(true)}
|
||
className="gap-2 btn-blue-gradient"
|
||
>
|
||
<MapIcon className="h-4 w-4 text-white" />
|
||
Show Map
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||
<ScrollArea className="h-[300px] p-6 pt-3">
|
||
<div className="space-y-3">
|
||
{/* Project Info */}
|
||
<div className="pb-3 border-b mb-3">
|
||
<div className="flex flex-col mb-3">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-0.5">Project</span>
|
||
<span className="text-base font-bold text-gray-900 dark:text-white">
|
||
{summaryData.project.name}
|
||
</span>
|
||
</div>
|
||
{summaryData.project.corridor_name && (
|
||
<div className="flex flex-col">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-0.5">Corridor</span>
|
||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">
|
||
{summaryData.project.corridor_name}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Packages and Locations */}
|
||
{Object.entries(summaryData.packages).map(([packageName, packageData]) => (
|
||
<div key={packageData.package_id} className="space-y-2">
|
||
<div className="text-xs font-medium">{packageName}</div>
|
||
<div className="pl-3 space-y-2">
|
||
{Object.entries(packageData.locations).map(([locationName, locationData]) => (
|
||
<div key={locationData.location_id} className="text-xs p-2 rounded bg-muted/30 flex items-center justify-between gap-4">
|
||
<div className="font-medium truncate">{locationName}</div>
|
||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||
{locationData.detection_count} detection{locationData.detection_count !== 1 ? "s" : ""} found
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-white/60 backdrop-blur-md rounded-md overflow-hidden flex flex-col lg:col-span-3">
|
||
<CardHeader className="pb-3 ">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
|
||
<div className="absolute inset-0 bg-white/10 opacity-40"></div>
|
||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white relative z-10">
|
||
<path d="M3 17L9 11L13 15L21 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
<path d="M15 7H21V13" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
|
||
All Detections
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Complete list of {isCombined ? "potholes & signboards" : isPothole ? "potholes" : "signboards"} with GPS coordinates
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||
<ScrollArea className="h-[300px] p-4">
|
||
<div className="space-y-2">
|
||
{allDetections.map(({ detection, locationName, packageName }, idx) => (
|
||
<div
|
||
key={`${detection.id}-${idx}`}
|
||
className="text-xs p-2 bg-white dark:bg-gray-900 rounded shadow-sm border border-gray-100 dark:border-gray-800"
|
||
|
||
>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="font-semibold">
|
||
{detection.type === "pothole" ? "Pothole" : (detection.class || detection.type || "").replace(/_/g, " ")} #{detection.id}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
Frame {detection.frame_number}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-0.5 text-[10px] text-muted-foreground">
|
||
<div>Location: {locationName}</div>
|
||
<div>Confidence: {(detection.confidence * 100).toFixed(1)}%</div>
|
||
<div className="font-mono">
|
||
GPS: {detection.latitude}, {detection.longitude}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Map Modal */}
|
||
<MapModal
|
||
open={showMap}
|
||
onClose={() => setShowMap(false)}
|
||
detections={allDetections.map(({ detection }) => detection)}
|
||
detectionType={detectionType}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SummarySection({ data, show, detectionType }: { data: DetectionData; show: boolean; detectionType: DetectionType }) {
|
||
if (!show) return null
|
||
|
||
const isCombined = detectionType === "pot-sign-detection"
|
||
const isPothole = detectionType === "pothole-detection" || isCombined
|
||
const isSignboard = detectionType === "sign-board-detection" || isCombined
|
||
|
||
const stats = [
|
||
{
|
||
label: "Total Road Damage",
|
||
value: data.summary.total_road_damage || 0,
|
||
icon: Target,
|
||
color: "text-purple-500",
|
||
bgColor: "bg-purple-50 dark:bg-purple-950/30",
|
||
},
|
||
{
|
||
label: "Defected Signboards",
|
||
value: data.summary.unique_defected_sign_board || 0,
|
||
icon: SignpostBig,
|
||
color: "text-blue-500",
|
||
bgColor: "bg-blue-50 dark:bg-blue-950/30",
|
||
},
|
||
{
|
||
label: "Unique Potholes",
|
||
value: data.summary.unique_pothole || 0,
|
||
icon: AlertTriangle,
|
||
color: "text-red-500",
|
||
bgColor: "bg-red-50 dark:bg-red-950/30",
|
||
},
|
||
{
|
||
label: "Road Cracks",
|
||
value: data.summary.unique_road_crack || 0,
|
||
icon: AlertTriangle,
|
||
color: "text-orange-500",
|
||
bgColor: "bg-orange-50 dark:bg-orange-950/30",
|
||
},
|
||
{
|
||
label: "Damaged Markings",
|
||
value: data.summary.unique_damaged_road_marking || 0,
|
||
icon: Activity,
|
||
color: "text-indigo-500",
|
||
bgColor: "bg-indigo-50 dark:bg-indigo-950/30",
|
||
},
|
||
{
|
||
label: "Good Signboards",
|
||
value: data.summary.unique_good_sign_board || 0,
|
||
icon: SignpostBig,
|
||
color: "text-emerald-500",
|
||
bgColor: "bg-emerald-50 dark:bg-emerald-950/30",
|
||
},
|
||
{
|
||
label: "Detection Rate",
|
||
value: `${(data.summary.detection_rate || 0).toFixed(1)}%`,
|
||
icon: Activity,
|
||
color: "text-green-500",
|
||
bgColor: "bg-green-50 dark:bg-green-950/30",
|
||
},
|
||
{
|
||
label: "Video FPS",
|
||
value: (data.video_info.fps || 0).toFixed(1),
|
||
icon: Gauge,
|
||
color: "text-orange-500",
|
||
bgColor: "bg-orange-50 dark:bg-orange-950/30",
|
||
},
|
||
{
|
||
label: "Resolution",
|
||
value: `${data.video_info.width}×${data.video_info.height}`,
|
||
icon: Monitor,
|
||
color: "text-blue-500",
|
||
bgColor: "bg-blue-50 dark:bg-blue-950/30",
|
||
},
|
||
{
|
||
label: "Total Frames",
|
||
value: data.summary.total_frames || data.video_info.total_frames,
|
||
icon: Film,
|
||
color: "text-purple-500",
|
||
bgColor: "bg-purple-50 dark:bg-purple-950/30",
|
||
},
|
||
]
|
||
|
||
return (
|
||
<Card className="bg-white/60 backdrop-blur-md rounded-xl overflow-hidden">
|
||
<CardHeader className="">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center relative overflow-hidden group">
|
||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white relative z-10">
|
||
<path d="M3 17L9 11L13 15L21 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
<path d="M15 7H21V13" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold text-gray-900 dark:text-white">
|
||
Quick Stats
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Overview of {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} detection results
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||
{stats.map((stat, index) => {
|
||
const Icon = stat.icon
|
||
return (
|
||
<div
|
||
key={stat.label}
|
||
className="flex flex-col items-center justify-center p-3 rounded-xl bg-white/80 backdrop-blur-md hover:scale-105 cursor-pointer transition-all duration-300 ease-in-out"
|
||
>
|
||
<div className={`${stat.bgColor} p-3 rounded-lg mb-2 transition-all`}>
|
||
<Icon className={`h-6 w-6 ${stat.color}`} />
|
||
</div>
|
||
<div className={`text-xl font-bold ${stat.color} mb-1`}>{stat.value}</div>
|
||
<div className="text-[10px] text-muted-foreground text-center leading-tight font-medium uppercase tracking-wide">{stat.label}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export default function VideoPlayerSection({ data, videoId, videoFile, detectionType, projectId }: VideoPlayerSectionProps) {
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const [currentFrame, setCurrentFrame] = useState(0)
|
||
const [detectionsCount, setDetectionsCount] = useState(0)
|
||
const [logs, setLogs] = useState<DetectionLog[]>([])
|
||
const [showSummary, setShowSummary] = useState(false)
|
||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false)
|
||
const [videoError, setVideoError] = useState<string | null>(null)
|
||
const [lastDetectedLat, setLastDetectedLat] = useState<number | null>(null)
|
||
const [lastDetectedLng, setLastDetectedLng] = useState<number | null>(null)
|
||
const [currentFrameCounts, setCurrentFrameCounts] = useState<Record<string, number>>({
|
||
defected_sign_board: 0,
|
||
pothole: 0,
|
||
road_crack: 0,
|
||
damaged_road_marking: 0,
|
||
good_sign_board: 0
|
||
})
|
||
const frameDetectionMap = useRef<Map<number, any[]>>(new Map())
|
||
const lastProcessedFrame = useRef(-1)
|
||
const loggedFrames = useRef<Set<number>>(new Set())
|
||
const cumulativeCountsMap = useRef<Map<number, Record<string, number>>>(new Map())
|
||
const sortedFrameIndices = useRef<number[]>([])
|
||
const detectedFrameSkip = useRef<number>(3) // Computed from data, fallback to 3
|
||
const MAX_LOGS = 50
|
||
|
||
const isCombined = detectionType === "pot-sign-detection"
|
||
const isPothole = detectionType === "pothole-detection" || isCombined
|
||
const isSignboard = detectionType === "sign-board-detection" || isCombined
|
||
|
||
// Create GPS coordinate map from signboard_list or pothole_list
|
||
const gpsMap = useRef<Map<number, { lat: number; lng: number }>>(new Map())
|
||
|
||
// Build GPS map from all available detection lists
|
||
useEffect(() => {
|
||
const map = new Map()
|
||
|
||
const addItemsToMap = (list?: any[]) => {
|
||
if (!list || !Array.isArray(list)) return
|
||
list.forEach(item => {
|
||
const id = (item as any).pothole_id ?? (item as any).signboard_id ?? (item as any).detection_id
|
||
if (item.lat !== undefined && item.lng !== undefined && id !== undefined) {
|
||
map.set(id, { lat: item.lat, lng: item.lng })
|
||
}
|
||
})
|
||
}
|
||
|
||
addItemsToMap(data.pothole_list)
|
||
addItemsToMap(data.signboard_list)
|
||
addItemsToMap(data.defected_sign_board_list)
|
||
addItemsToMap(data.road_crack_list)
|
||
addItemsToMap(data.damaged_road_marking_list)
|
||
addItemsToMap(data.good_sign_board_list)
|
||
|
||
gpsMap.current = map
|
||
console.log(`[VideoPlayer] GPS map built with ${map.size} entries mapping to IDs`)
|
||
}, [data])
|
||
|
||
// Helper function to format video time from frame number
|
||
const formatVideoTime = useCallback((frame: number, fps: number): string => {
|
||
const seconds = frame / fps
|
||
const hours = Math.floor(seconds / 3600)
|
||
const minutes = Math.floor((seconds % 3600) / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
|
||
if (hours > 0) {
|
||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||
}, [])
|
||
|
||
// Function to seek video to a specific frame
|
||
const seekToFrame = useCallback((frame: number) => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
// Calculate time from frame number
|
||
const time = frame / data.video_info.fps
|
||
|
||
// Set video currentTime (this will trigger seeked event)
|
||
video.currentTime = time
|
||
|
||
console.log(`[SeekToFrame] Jumping to frame ${frame} at ${time.toFixed(2)}s`)
|
||
}, [data.video_info.fps])
|
||
|
||
// Build optimized frame detection map
|
||
useEffect(() => {
|
||
const map = new Map()
|
||
|
||
if (data.frames && Array.isArray(data.frames)) {
|
||
console.log(`[VideoPlayer] Building frame map from ${data.frames.length} frames`)
|
||
data.frames.forEach((frameData) => {
|
||
const frameId = frameData.frame_id
|
||
|
||
// Handle flat detections array format (used by pot-sign-detection API)
|
||
const flatDetections = (frameData as any).detections
|
||
if (flatDetections && Array.isArray(flatDetections)) {
|
||
const tagged = flatDetections.map((d: any) => ({
|
||
...d,
|
||
_detType: d.type === 'pothole' ? 'pothole' : 'signboard',
|
||
pothole_id: d.type === 'pothole' ? d.detection_id : undefined,
|
||
signboard_id: d.type !== 'pothole' ? d.detection_id : undefined,
|
||
}))
|
||
if (tagged.length > 0) {
|
||
map.set(frameId, tagged)
|
||
}
|
||
} else {
|
||
// Handle separate potholes/signboards arrays format
|
||
let detections: any[] = []
|
||
if (isPothole && frameData.potholes) {
|
||
detections = [...detections, ...frameData.potholes.map((p: any) => ({ ...p, _detType: 'pothole' }))]
|
||
}
|
||
if (isSignboard && frameData.signboards) {
|
||
detections = [...detections, ...frameData.signboards.map((s: any) => ({ ...s, _detType: 'signboard' }))]
|
||
}
|
||
if (detections.length > 0) {
|
||
map.set(frameId, detections)
|
||
}
|
||
}
|
||
})
|
||
console.log(`[VideoPlayer] Frame map built: ${map.size} frames with detections`)
|
||
}
|
||
|
||
frameDetectionMap.current = map
|
||
console.log(`[VideoPlayer] Frame map built: ${map.size} frames with detections`)
|
||
}, [data, isPothole, isSignboard])
|
||
|
||
// Build cumulative counts map (Sticky Counts)
|
||
useEffect(() => {
|
||
const map = new Map<number, Record<string, number>>()
|
||
let lastCounts = {
|
||
defected_sign_board: 0,
|
||
pothole: 0,
|
||
road_crack: 0,
|
||
damaged_road_marking: 0,
|
||
good_sign_board: 0
|
||
}
|
||
|
||
if (data.frames && Array.isArray(data.frames)) {
|
||
// Sort frames by ID to ensure we process them in order
|
||
const sortedFrames = [...data.frames].sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0))
|
||
const indices: number[] = []
|
||
|
||
sortedFrames.forEach((frameData) => {
|
||
const frameId = frameData.frame_id
|
||
indices.push(frameId)
|
||
const detections = (frameData as any).detections
|
||
|
||
if (detections && detections.length > 0) {
|
||
const det0 = detections[0]
|
||
let frameCounts: Record<string, number>
|
||
|
||
if (det0.count) {
|
||
frameCounts = { ...det0.count }
|
||
} else {
|
||
// Manual count if missing
|
||
frameCounts = {
|
||
defected_sign_board: 0,
|
||
pothole: 0,
|
||
road_crack: 0,
|
||
damaged_road_marking: 0,
|
||
good_sign_board: 0
|
||
}
|
||
detections.forEach((d: any) => {
|
||
const type = (d.type || '').split(' ')[0].toLowerCase() // Handle potential spaces
|
||
if (frameCounts.hasOwnProperty(type)) {
|
||
frameCounts[type]++
|
||
} else if (type === 'pothole') {
|
||
frameCounts.pothole++
|
||
} else if (type.includes('defected')) {
|
||
frameCounts.defected_sign_board++
|
||
}
|
||
})
|
||
}
|
||
|
||
// Carry over and update max (Sticky logic)
|
||
lastCounts = {
|
||
defected_sign_board: Math.max(lastCounts.defected_sign_board, frameCounts.defected_sign_board || 0),
|
||
pothole: Math.max(lastCounts.pothole, frameCounts.pothole || 0),
|
||
road_crack: Math.max(lastCounts.road_crack, frameCounts.road_crack || 0),
|
||
damaged_road_marking: Math.max(lastCounts.damaged_road_marking, frameCounts.damaged_road_marking || 0),
|
||
good_sign_board: Math.max(lastCounts.good_sign_board, frameCounts.good_sign_board || 0),
|
||
}
|
||
}
|
||
|
||
map.set(frameId, { ...lastCounts })
|
||
})
|
||
sortedFrameIndices.current = indices
|
||
|
||
// Compute the frame skip gap from the data (minimum gap between consecutive detection frames)
|
||
// This tells us how far apart processed frames are, so we only carry forward within that gap
|
||
if (indices.length >= 2) {
|
||
let minGap = Infinity
|
||
for (let i = 1; i < indices.length; i++) {
|
||
const gap = indices[i] - indices[i - 1]
|
||
if (gap > 0 && gap < minGap) minGap = gap
|
||
}
|
||
detectedFrameSkip.current = minGap === Infinity ? 3 : minGap
|
||
console.log(`[VideoPlayer] Detected frame skip gap: ${detectedFrameSkip.current}`)
|
||
}
|
||
}
|
||
|
||
cumulativeCountsMap.current = map
|
||
console.log(`[VideoPlayer] Sticky cumulative counts map built for ${map.size} frames`)
|
||
}, [data.frames])
|
||
|
||
// Helper to get sticky counts for any frame
|
||
const getStickyCounts = useCallback((frame: number) => {
|
||
// Find the largest frame index in our map that is <= current frame
|
||
const indices = sortedFrameIndices.current
|
||
let targetIndex = -1
|
||
|
||
// Quick binary search for the latest frame with detection data
|
||
let low = 0, high = indices.length - 1
|
||
while (low <= high) {
|
||
let mid = Math.floor((low + high) / 2)
|
||
if (indices[mid] <= frame) {
|
||
targetIndex = indices[mid]
|
||
low = mid + 1
|
||
} else {
|
||
high = mid - 1
|
||
}
|
||
}
|
||
|
||
if (targetIndex !== -1) {
|
||
return cumulativeCountsMap.current.get(targetIndex) || {
|
||
defected_sign_board: 0,
|
||
pothole: 0,
|
||
road_crack: 0,
|
||
damaged_road_marking: 0,
|
||
good_sign_board: 0
|
||
}
|
||
}
|
||
|
||
return {
|
||
defected_sign_board: 0,
|
||
pothole: 0,
|
||
road_crack: 0,
|
||
damaged_road_marking: 0,
|
||
good_sign_board: 0
|
||
}
|
||
}, [])
|
||
|
||
// Helper to get nearest detections for any frame (handles frame skipping)
|
||
// Uses binary search to find the closest processed frame <= current frame,
|
||
// but only carries forward within the frame-skip gap to avoid stale bounding boxes
|
||
const getNearestDetections = useCallback((frame: number): any[] | undefined => {
|
||
// First try exact match (fastest path)
|
||
const exact = frameDetectionMap.current.get(frame)
|
||
if (exact) return exact
|
||
|
||
// Binary search for the nearest processed frame <= current frame
|
||
const indices = sortedFrameIndices.current
|
||
if (indices.length === 0) return undefined
|
||
|
||
let low = 0, high = indices.length - 1
|
||
let targetIndex = -1
|
||
|
||
while (low <= high) {
|
||
const mid = Math.floor((low + high) / 2)
|
||
if (indices[mid] <= frame) {
|
||
targetIndex = indices[mid]
|
||
low = mid + 1
|
||
} else {
|
||
high = mid - 1
|
||
}
|
||
}
|
||
|
||
if (targetIndex !== -1) {
|
||
// Only carry forward if we're within the frame-skip gap
|
||
// This prevents bounding boxes from persisting after the detection has ended
|
||
const gap = frame - targetIndex
|
||
if (gap <= detectedFrameSkip.current) {
|
||
return frameDetectionMap.current.get(targetIndex)
|
||
}
|
||
}
|
||
|
||
return undefined
|
||
}, [])
|
||
|
||
// Function to draw bounding boxes on canvas
|
||
const drawBoundingBoxes = useCallback((detections: any[]) => {
|
||
const canvas = canvasRef.current
|
||
const video = videoRef.current
|
||
|
||
if (!canvas || !video) return
|
||
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
|
||
if (!detections || detections.length === 0) return
|
||
|
||
// Calculate scale between video natural size and displayed size
|
||
const scaleX = canvas.width / data.video_info.width
|
||
const scaleY = canvas.height / data.video_info.height
|
||
|
||
detections.forEach((detection) => {
|
||
const bbox = detection.bbox
|
||
if (!bbox) return
|
||
|
||
// Scale coordinates to match displayed video size
|
||
const x1 = bbox.x1 * scaleX
|
||
const y1 = bbox.y1 * scaleY
|
||
const x2 = bbox.x2 * scaleX
|
||
const y2 = bbox.y2 * scaleY
|
||
const width = x2 - x1
|
||
const height = y2 - y1
|
||
|
||
// Map individual detection types to colors
|
||
const type = (detection.type || detection._detType || '').toLowerCase()
|
||
const detectionColors: Record<string, string> = {
|
||
'pothole': '#ef4444',
|
||
'defected_sign_board': '#3b82f6',
|
||
'road_crack': '#f59e0b',
|
||
'damaged_road_marking': '#6366f1',
|
||
'good_sign_board': '#10b981',
|
||
'signboard': '#3b82f6' // fallback
|
||
}
|
||
|
||
const boxColor = detectionColors[type] || '#3b82f6'
|
||
const textBgColor = boxColor + 'e6' // Adding alpha
|
||
|
||
// Draw bounding box
|
||
ctx.strokeStyle = boxColor
|
||
ctx.lineWidth = 3
|
||
ctx.strokeRect(x1, y1, width, height)
|
||
|
||
// Draw semi-transparent fill
|
||
ctx.fillStyle = type === 'pothole' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)'
|
||
ctx.fillRect(x1, y1, width, height)
|
||
|
||
// Prepare label text
|
||
const id = detection.pothole_id ?? detection.signboard_id ?? detection.detection_id
|
||
const confidence = (detection.confidence * 100).toFixed(1)
|
||
let labelText = type === 'pothole'
|
||
? `Pothole #${id}`
|
||
: `${(detection.type || 'Sign').replace(/_/g, ' ')} #${id}`
|
||
labelText += ` ${confidence}%`
|
||
|
||
// Draw label background
|
||
ctx.font = 'bold 14px system-ui'
|
||
const textMetrics = ctx.measureText(labelText)
|
||
const textWidth = textMetrics.width + 16
|
||
const textHeight = 26
|
||
|
||
ctx.fillStyle = textBgColor
|
||
ctx.fillRect(x1, y1 - textHeight - 2, textWidth, textHeight)
|
||
|
||
// Draw label text
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.fillText(labelText, x1 + 8, y1 - 8)
|
||
})
|
||
}, [data.video_info.width, data.video_info.height])
|
||
|
||
// Resize canvas to match video display size
|
||
const resizeCanvas = useCallback(() => {
|
||
const video = videoRef.current
|
||
const canvas = canvasRef.current
|
||
const container = containerRef.current
|
||
|
||
if (!video || !canvas || !container) return
|
||
|
||
// Get the displayed size of the video
|
||
const rect = video.getBoundingClientRect()
|
||
canvas.width = rect.width
|
||
canvas.height = rect.height
|
||
|
||
// Redraw current frame's detections (using nearest frame for skipped frames)
|
||
const frame = Math.round(video.currentTime * data.video_info.fps)
|
||
const detections = getNearestDetections(frame)
|
||
if (detections) {
|
||
drawBoundingBoxes(detections)
|
||
}
|
||
}, [data.video_info.fps, drawBoundingBoxes, getNearestDetections])
|
||
|
||
// Handle window resize
|
||
useEffect(() => {
|
||
window.addEventListener('resize', resizeCanvas)
|
||
return () => window.removeEventListener('resize', resizeCanvas)
|
||
}, [resizeCanvas])
|
||
|
||
// Initialize canvas size when video loads
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
const handleLoadedMetadata = () => {
|
||
resizeCanvas()
|
||
}
|
||
|
||
video.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||
return () => video.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||
}, [resizeCanvas])
|
||
|
||
// Load video from uploaded file or from server
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
let videoUrl: string
|
||
|
||
if (videoFile) {
|
||
// Load from local file (upload page)
|
||
videoUrl = URL.createObjectURL(videoFile)
|
||
console.log(`[VideoPlayer] Loading video from uploaded file`)
|
||
} else if (videoId) {
|
||
// Load from server (results page)
|
||
videoUrl = `${API_URL}/video/${videoId}`
|
||
console.log(`[VideoPlayer] Loading video from server: ${videoUrl}`)
|
||
} else {
|
||
return
|
||
}
|
||
|
||
video.src = videoUrl
|
||
|
||
// Handle video load errors
|
||
const handleError = () => {
|
||
console.error("[VideoPlayer] Failed to load video")
|
||
setVideoError("Failed to load video. Please try refreshing the page.")
|
||
}
|
||
|
||
const handleLoaded = () => {
|
||
console.log("[VideoPlayer] Video loaded successfully")
|
||
setVideoError(null)
|
||
resizeCanvas()
|
||
}
|
||
|
||
video.addEventListener("error", handleError)
|
||
video.addEventListener("loadeddata", handleLoaded)
|
||
|
||
return () => {
|
||
video.removeEventListener("error", handleError)
|
||
video.removeEventListener("loadeddata", handleLoaded)
|
||
// Revoke object URL only if it was created from a file
|
||
if (videoFile) {
|
||
URL.revokeObjectURL(videoUrl)
|
||
}
|
||
}
|
||
}, [videoFile, videoId, resizeCanvas])
|
||
|
||
// Add detection log with deduplication and size limit
|
||
const addDetectionLog = useCallback((frame: number, detections: any[]) => {
|
||
if (loggedFrames.current.has(frame)) return
|
||
|
||
loggedFrames.current.add(frame)
|
||
|
||
// Update last detected GPS coordinates if available
|
||
if (detections.length > 0) {
|
||
const det0 = detections[0]
|
||
const isDetPothole = det0._detType === 'pothole' || det0.type === 'pothole' || (det0.pothole_id !== undefined && !det0.signboard_id)
|
||
const detectionId = det0.pothole_id ?? det0.signboard_id ?? det0.detection_id
|
||
const gpsCoords = gpsMap.current.get(detectionId)
|
||
|
||
if (gpsCoords) {
|
||
setLastDetectedLat(gpsCoords.lat)
|
||
setLastDetectedLng(gpsCoords.lng)
|
||
}
|
||
}
|
||
|
||
setLogs((prev) => {
|
||
const newLog: DetectionLog = {
|
||
frame,
|
||
detections: detections.map((det) => {
|
||
const isDetPothole = det._detType === 'pothole' || det.type === 'pothole' || (det.pothole_id !== undefined && !det.signboard_id)
|
||
const detectionId = det.pothole_id ?? det.signboard_id ?? det.detection_id
|
||
const gpsCoords = gpsMap.current.get(detectionId)
|
||
|
||
return {
|
||
id: detectionId,
|
||
type: isDetPothole ? 'pothole' : (det.type || 'signboard'),
|
||
bbox: det.bbox,
|
||
confidence: det.confidence,
|
||
latitude: gpsCoords?.lat,
|
||
longitude: gpsCoords?.lng,
|
||
}
|
||
}),
|
||
videoTime: formatVideoTime(frame, data.video_info.fps),
|
||
}
|
||
|
||
const updated = [newLog, ...prev].slice(0, MAX_LOGS)
|
||
return updated
|
||
})
|
||
}, [isPothole, isSignboard, formatVideoTime, data.video_info.fps])
|
||
|
||
// Track current frame and log detections
|
||
const updateFrameInfo = useCallback(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
const frame = Math.round(video.currentTime * data.video_info.fps)
|
||
|
||
if (!video.paused && !video.ended && frame !== lastProcessedFrame.current) {
|
||
lastProcessedFrame.current = frame
|
||
setCurrentFrame(frame)
|
||
|
||
// Use nearest processed frame's detections to avoid flickering from frame skipping
|
||
const detections = getNearestDetections(frame)
|
||
setDetectionsCount(detections?.length || 0)
|
||
|
||
// Draw bounding boxes for current frame (carried forward from nearest processed frame)
|
||
drawBoundingBoxes(detections || [])
|
||
|
||
// Update counts using sticky logic
|
||
setCurrentFrameCounts(getStickyCounts(frame))
|
||
|
||
if (detections && detections.length > 0) {
|
||
addDetectionLog(frame, detections)
|
||
}
|
||
}
|
||
}, [data.video_info.fps, addDetectionLog, drawBoundingBoxes, getNearestDetections])
|
||
|
||
// Video playback monitoring
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
let animationId: number
|
||
|
||
const animate = () => {
|
||
updateFrameInfo()
|
||
animationId = requestAnimationFrame(animate)
|
||
}
|
||
|
||
animationId = requestAnimationFrame(animate)
|
||
|
||
return () => {
|
||
cancelAnimationFrame(animationId)
|
||
}
|
||
}, [updateFrameInfo])
|
||
|
||
// Handle video end - show summary
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
const handleEnded = () => {
|
||
if (!hasPlayedOnce) {
|
||
setHasPlayedOnce(true)
|
||
setShowSummary(true)
|
||
}
|
||
}
|
||
|
||
video.addEventListener("ended", handleEnded)
|
||
|
||
return () => {
|
||
video.removeEventListener("ended", handleEnded)
|
||
}
|
||
}, [hasPlayedOnce])
|
||
|
||
// Handle seeking and time updates
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
|
||
const handleTimeUpdate = () => {
|
||
const frame = Math.round(video.currentTime * data.video_info.fps)
|
||
setCurrentFrame(frame)
|
||
|
||
const detections = frameDetectionMap.current.get(frame)
|
||
setDetectionsCount(detections?.length || 0)
|
||
|
||
// Draw bounding boxes for current frame
|
||
drawBoundingBoxes(detections || [])
|
||
}
|
||
|
||
const handleSeeked = () => {
|
||
// Clear logged frames to allow re-logging if seeking back
|
||
loggedFrames.current.clear()
|
||
|
||
// Immediately update frame info
|
||
const frame = Math.round(video.currentTime * data.video_info.fps)
|
||
setCurrentFrame(frame)
|
||
|
||
const detections = frameDetectionMap.current.get(frame)
|
||
setDetectionsCount(detections?.length || 0)
|
||
|
||
// Update sticky counts immediately on seek
|
||
setCurrentFrameCounts(getStickyCounts(frame))
|
||
|
||
if (detections && detections.length > 0) {
|
||
const det0 = detections[0]
|
||
const isDetPothole = det0._detType === 'pothole' || det0.type === 'pothole' || (det0.pothole_id !== undefined && !det0.signboard_id)
|
||
const detectionId = det0.pothole_id ?? det0.signboard_id ?? det0.detection_id
|
||
const gpsCoords = gpsMap.current.get(detectionId)
|
||
|
||
if (gpsCoords) {
|
||
setLastDetectedLat(gpsCoords.lat)
|
||
setLastDetectedLng(gpsCoords.lng)
|
||
console.log(`[Seek] Updated GPS: ${gpsCoords.lat}, ${gpsCoords.lng} at frame ${frame}`)
|
||
}
|
||
|
||
// Add detection log for the seeked frame
|
||
addDetectionLog(frame, detections)
|
||
}
|
||
|
||
// Draw bounding boxes for seeked frame
|
||
drawBoundingBoxes(detections || [])
|
||
}
|
||
|
||
const handlePlay = () => {
|
||
if (video.currentTime < 0.1) {
|
||
setLogs([])
|
||
loggedFrames.current.clear()
|
||
lastProcessedFrame.current = -1
|
||
}
|
||
}
|
||
|
||
video.addEventListener("timeupdate", handleTimeUpdate)
|
||
video.addEventListener("seeked", handleSeeked)
|
||
video.addEventListener("play", handlePlay)
|
||
|
||
return () => {
|
||
video.removeEventListener("timeupdate", handleTimeUpdate)
|
||
video.removeEventListener("seeked", handleSeeked)
|
||
video.removeEventListener("play", handlePlay)
|
||
}
|
||
}, [data.video_info.fps, drawBoundingBoxes, isPothole, addDetectionLog])
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<Card className="bg-white/60 backdrop-blur-lg rounded-xl overflow-hidden">
|
||
<CardHeader className="">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF]">
|
||
<Film className="h-8 w-8 text-white" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-lg font-bold text-gray-900 dark:text-white">
|
||
Video Playback with Detection
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Watch the video with real-time {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} detection overlays
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Video Player with Canvas Overlay */}
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{videoError && (
|
||
<div className="bg-destructive/10 text-destructive p-4 rounded-lg mb-4">
|
||
{videoError}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className="relative bg-black rounded-lg overflow-hidden"
|
||
style={{
|
||
aspectRatio: `${data.video_info.width} / ${data.video_info.height}`,
|
||
}}
|
||
>
|
||
<video
|
||
ref={videoRef}
|
||
controls
|
||
className="w-full h-full"
|
||
>
|
||
Your browser does not support the video tag.
|
||
</video>
|
||
|
||
{/* Canvas overlay for bounding boxes */}
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="absolute top-0 left-0 pointer-events-none"
|
||
style={{ width: '100%', height: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-x-4 gap-y-2 text-[10px] font-medium overflow-hidden">
|
||
{/* Detection Counts - Single line Flexbox */}
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-bold text-red-500 uppercase tracking-tight">Pothole:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400 border-none">{currentFrameCounts.pothole}</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-bold text-blue-500 uppercase tracking-tight whitespace-nowrap">Defect Sign Board:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 border-none">{currentFrameCounts.defected_sign_board}</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-bold text-indigo-500 uppercase tracking-tight whitespace-nowrap">Damage Road Mark:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-indigo-50 text-indigo-700 dark:bg-indigo-950/30 dark:text-indigo-400 border-none">{currentFrameCounts.damaged_road_marking}</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-bold text-yellow-500 uppercase tracking-tight whitespace-nowrap">RoadCrack:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-orange-50 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400 border-none">{currentFrameCounts.road_crack}</Badge>
|
||
</div>
|
||
{/* <div className="flex items-center gap-1.5">
|
||
<span className="font-bold text-emerald-500 uppercase tracking-tight whitespace-nowrap">Good Sign Board:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 border-none">{currentFrameCounts.good_sign_board}</Badge>
|
||
</div> */}
|
||
</div>
|
||
|
||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-800 shrink-0" />
|
||
|
||
<div className="flex items-center gap-4 shrink-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-muted-foreground whitespace-nowrap">FRAME:</span>
|
||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 py-0">{currentFrame}</Badge>
|
||
</div>
|
||
{lastDetectedLat !== null && lastDetectedLng !== null && (
|
||
<>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-muted-foreground uppercase font-semibold">LAT:</span>
|
||
<Badge variant="outline" className="font-mono text-[10px] h-5 px-1.5 py-0 border-blue-200 dark:border-blue-800">
|
||
{lastDetectedLat}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-muted-foreground uppercase font-semibold">LNG:</span>
|
||
<Badge variant="outline" className="font-mono text-[10px] h-5 px-1.5 py-0 border-blue-200 dark:border-blue-800">
|
||
{lastDetectedLng}
|
||
</Badge>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detection Logs */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h4 className="text-sm font-semibold mb-2">Detection Logs</h4>
|
||
<p className="text-xs text-muted-foreground mb-3">
|
||
Real-time frame-by-frame {isCombined ? "pothole & signboard" : isPothole ? "pothole" : "signboard"} tracking (last {MAX_LOGS})
|
||
</p>
|
||
</div>
|
||
<ScrollArea className="h-[400px] rounded-md border bg-muted/30 p-4">
|
||
<div className="space-y-2">
|
||
{logs.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground text-center py-8">
|
||
Play the video to see detection logs
|
||
</p>
|
||
) : (
|
||
logs.map((log, index) => (
|
||
<div
|
||
key={`${log.frame}-${index}`}
|
||
onClick={() => seekToFrame(log.frame)}
|
||
className="text-xs p-3 rounded-md border border-slate-200 bg-white cursor-pointer transition-colors"
|
||
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="font-semibold text-foreground">Frame: {log.frame}</span>
|
||
<span className="text-muted-foreground text-[10px]">{log.videoTime}</span>
|
||
</div>
|
||
|
||
{log.detections.length === 0 ? (
|
||
<div className="text-muted-foreground">No detections</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{log.detections.map((det, idx) => (
|
||
<div key={idx} className="space-y-1">
|
||
<div className="font-medium text-foreground">
|
||
{det.type === 'pothole' ? (
|
||
<>Pothole ID: {det.id}</>
|
||
) : (
|
||
<>{det.type || 'Signboard'} ID: {det.id}</>
|
||
)}
|
||
{" "}| Confidence: {(det.confidence * 100).toFixed(1)}%
|
||
</div>
|
||
<div className="text-muted-foreground font-mono text-[10px]">
|
||
Coordinates: ({Math.round(det.bbox.x1)}, {Math.round(det.bbox.y1)}) → ({Math.round(det.bbox.x2)}, {Math.round(det.bbox.y2)})
|
||
</div>
|
||
{det.latitude !== undefined && det.longitude !== undefined ? (
|
||
<div className="text-muted-foreground font-mono text-[10px]">
|
||
GPS: {det.latitude}, {det.longitude}
|
||
</div>
|
||
) : (
|
||
<div className="text-destructive/70 font-mono text-[10px]">
|
||
GPS: Data Unavail.
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Summary Sections - Show after first complete playback and persist */}
|
||
{showSummary && (
|
||
<div className="space-y-4">
|
||
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
|
||
<DetailedSummarySection projectId={projectId || ""} videoId={videoId} show={showSummary} detectionType={detectionType} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} |