695 lines
31 KiB
TypeScript
695 lines
31 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 "@/types"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
import { projectService } from "@/services/api"
|
||
|
||
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 ChainageSummaryData = {
|
||
project: {
|
||
id: string
|
||
name: string
|
||
corridor_name: string | null
|
||
state: string | null
|
||
}
|
||
packages: {
|
||
[packageName: string]: {
|
||
package_id: string
|
||
region: string | null
|
||
chainages: {
|
||
[chainageName: string]: {
|
||
chainage_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<ChainageSummaryData | 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 data = await projectService.getProjectSummaryByVideo(projectId, videoId);
|
||
setSummaryData(data)
|
||
} 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
|
||
chainageName: string
|
||
packageName: string
|
||
}> = []
|
||
|
||
Object.entries(summaryData.packages || {}).forEach(([packageName, packageData]) => {
|
||
Object.entries(packageData?.chainages || {}).forEach(([chainageName, chainageData]) => {
|
||
chainageData?.detections?.forEach(detection => {
|
||
allDetections.push({ detection, chainageName, packageName })
|
||
})
|
||
})
|
||
})
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||
<Card className="flex flex-col lg:col-span-2 overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded bg-secondary">
|
||
<MapIcon className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold">
|
||
Chainages
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
{isCombined ? "Potholes & Signboards" : isPothole ? "Potholes" : "Signboards"} detected
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setShowMap(true)}
|
||
className="gap-2"
|
||
>
|
||
<MapIcon className="h-4 w-4" />
|
||
Map
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||
<ScrollArea className="h-[300px] p-4">
|
||
<div className="space-y-4">
|
||
{/* Project Info */}
|
||
<div className="pb-3 border-b">
|
||
<div className="flex flex-col mb-2">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Project</span>
|
||
<span className="text-sm font-bold">
|
||
{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">Corridor</span>
|
||
<span className="text-xs font-semibold text-muted-foreground">
|
||
{summaryData.project.corridor_name}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Packages and Chainages */}
|
||
{Object.entries(summaryData.packages).map(([packageName, packageData]) => (
|
||
<div key={packageData.package_id} className="space-y-2">
|
||
<div className="text-xs font-bold text-muted-foreground truncate">{packageName}</div>
|
||
<div className="pl-2 space-y-2 border-l-2 border-muted">
|
||
{Object.entries(packageData.chainages).map(([chainageName, chainageData]) => (
|
||
<div key={chainageData.chainage_id} className="text-xs p-3 rounded-md bg-muted/50 flex items-center justify-between gap-4">
|
||
<div className="font-semibold truncate">{chainageName}</div>
|
||
<div className="text-[10px] bg-background px-2 py-0.5 rounded border font-bold">
|
||
{chainageData.detection_count}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="flex flex-col lg:col-span-3 overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded bg-secondary">
|
||
<Activity className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold">
|
||
All Detections
|
||
</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Complete list 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, chainageName }, idx) => (
|
||
<div
|
||
key={`${detection.id}-${idx}`}
|
||
className="text-xs p-3 bg-muted/30 border rounded-md hover:bg-muted/50 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="font-bold">
|
||
{detection.type === "pothole" ? "Pothole" : (detection.class || detection.type || "").replace(/_/g, " ")} #{detection.id}
|
||
</span>
|
||
<Badge variant="outline" className="text-[10px] font-mono">
|
||
Frame {detection.frame_number}
|
||
</Badge>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 text-[10px] text-muted-foreground">
|
||
<div className="truncate">Chainage: <span className="text-foreground font-medium">{chainageName}</span></div>
|
||
<div>Confidence: <span className="text-foreground font-medium">{(detection.confidence * 100).toFixed(1)}%</span></div>
|
||
<div className="col-span-2 font-mono bg-muted/50 p-1 rounded border">
|
||
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 stats = [
|
||
{ label: "Total Damage", value: data.summary.total_road_damage || 0, icon: Target, color: "text-purple-500", bgColor: "bg-purple-500/10" },
|
||
{ label: "Defected Signs", value: data.summary.unique_defected_sign_board || 0, icon: SignpostBig, color: "text-blue-500", bgColor: "bg-blue-500/10" },
|
||
{ label: "Unique Potholes", value: data.summary.unique_pothole || 0, icon: AlertTriangle, color: "text-red-500", bgColor: "bg-red-500/10" },
|
||
{ label: "Road Cracks", value: data.summary.unique_road_crack || 0, icon: AlertTriangle, color: "text-orange-500", bgColor: "bg-orange-500/10" },
|
||
{ label: "Markings", value: data.summary.unique_damaged_road_marking || 0, icon: Activity, color: "text-indigo-500", bgColor: "bg-indigo-500/10" },
|
||
{ label: "Good Signs", value: data.summary.unique_good_sign_board || 0, icon: SignpostBig, color: "text-emerald-500", bgColor: "bg-emerald-500/10" },
|
||
{ label: "Rate", value: `${(data.summary.detection_rate || 0).toFixed(1)}%`, icon: Activity, color: "text-green-500", bgColor: "bg-green-500/10" },
|
||
{ label: "Video FPS", value: (data.video_info.fps || 0).toFixed(1), icon: Gauge, color: "text-orange-500", bgColor: "bg-orange-500/10" },
|
||
{ label: "Resolution", value: `${data.video_info.width}×${data.video_info.height}`, icon: Monitor, color: "text-blue-500", bgColor: "bg-blue-500/10" },
|
||
{ label: "Total Frames", value: data.summary.total_frames || data.video_info.total_frames, icon: Film, color: "text-purple-500", bgColor: "bg-purple-500/10" },
|
||
]
|
||
|
||
return (
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded bg-secondary">
|
||
<Activity className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-bold">Quick Stats</CardTitle>
|
||
<CardDescription className="text-xs">Detection analysis overview</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||
{stats.map((stat) => {
|
||
const Icon = stat.icon
|
||
return (
|
||
<div key={stat.label} className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center">
|
||
<div className={cn("p-2 rounded-md mb-2", stat.bgColor)}>
|
||
<Icon className={cn("h-5 w-5", stat.color)} />
|
||
</div>
|
||
<div className={cn("text-xl font-bold", stat.color)}>{stat.value}</div>
|
||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">{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)
|
||
const MAX_LOGS = 50
|
||
|
||
const isCombined = detectionType === "pot-sign-detection"
|
||
const isPothole = detectionType === "pothole-detection" || isCombined
|
||
const isSignboard = detectionType === "sign-board-detection" || isCombined
|
||
|
||
const gpsMap = useRef<Map<number, { lat: number; lng: number }>>(new Map())
|
||
|
||
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
|
||
}, [data])
|
||
|
||
const formatVideoTime = useCallback((frame: number, fps: number): string => {
|
||
const seconds = frame / fps
|
||
const minutes = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||
}, [])
|
||
|
||
const seekToFrame = useCallback((frame: number) => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
video.currentTime = frame / data.video_info.fps
|
||
}, [data.video_info.fps])
|
||
|
||
useEffect(() => {
|
||
const map = new Map()
|
||
if (data.frames && Array.isArray(data.frames)) {
|
||
data.frames.forEach((frameData) => {
|
||
const frameId = frameData.frame_id
|
||
const flatDetections = (frameData as any).detections
|
||
if (flatDetections && Array.isArray(flatDetections)) {
|
||
map.set(frameId, 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,
|
||
})))
|
||
} else {
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
frameDetectionMap.current = map
|
||
}, [data, isPothole, isSignboard])
|
||
|
||
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)) {
|
||
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 frameCounts = detections[0].count || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }
|
||
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
|
||
}
|
||
cumulativeCountsMap.current = map
|
||
}, [data.frames])
|
||
|
||
const getStickyCounts = useCallback((frame: number) => {
|
||
const indices = sortedFrameIndices.current
|
||
let targetIndex = -1
|
||
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
|
||
}
|
||
}
|
||
return targetIndex !== -1 ? (cumulativeCountsMap.current.get(targetIndex) || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }) : { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }
|
||
}, [])
|
||
|
||
const getNearestDetections = useCallback((frame: number): any[] | undefined => {
|
||
const exact = frameDetectionMap.current.get(frame)
|
||
if (exact) return exact
|
||
const indices = sortedFrameIndices.current
|
||
let low = 0, high = indices.length - 1, 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 }
|
||
}
|
||
return (targetIndex !== -1 && (frame - targetIndex) <= detectedFrameSkip.current) ? frameDetectionMap.current.get(targetIndex) : undefined
|
||
}, [])
|
||
|
||
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
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||
if (!detections || detections.length === 0) return
|
||
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
|
||
const x1 = bbox.x1 * scaleX, y1 = bbox.y1 * scaleY, x2 = bbox.x2 * scaleX, y2 = bbox.y2 * scaleY
|
||
const type = (detection.type || detection._detType || '').toLowerCase()
|
||
const colors: Record<string, string> = { 'pothole': '#ef4444', 'defected_sign_board': '#3b82f6', 'road_crack': '#f59e0b', 'damaged_road_marking': '#6366f1', 'good_sign_board': '#10b981' }
|
||
const boxColor = colors[type] || '#3b82f6'
|
||
ctx.strokeStyle = boxColor
|
||
ctx.lineWidth = 3
|
||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1)
|
||
ctx.fillStyle = boxColor + '20'
|
||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1)
|
||
const id = detection.pothole_id ?? detection.signboard_id ?? detection.detection_id
|
||
const label = `${type.replace(/_/g, ' ')} #${id} ${(detection.confidence * 100).toFixed(0)}%`
|
||
ctx.font = 'bold 12px sans-serif'
|
||
const metrics = ctx.measureText(label)
|
||
ctx.fillStyle = boxColor
|
||
ctx.fillRect(x1, y1 - 20, metrics.width + 10, 20)
|
||
ctx.fillStyle = '#fff'
|
||
ctx.fillText(label, x1 + 5, y1 - 6)
|
||
})
|
||
}, [data.video_info])
|
||
|
||
const resizeCanvas = useCallback(() => {
|
||
if (!videoRef.current || !canvasRef.current) return
|
||
const rect = videoRef.current.getBoundingClientRect()
|
||
canvasRef.current.width = rect.width
|
||
canvasRef.current.height = rect.height
|
||
const detections = getNearestDetections(Math.round(videoRef.current.currentTime * data.video_info.fps))
|
||
if (detections) drawBoundingBoxes(detections)
|
||
}, [data.video_info.fps, drawBoundingBoxes, getNearestDetections])
|
||
|
||
useEffect(() => {
|
||
window.addEventListener('resize', resizeCanvas)
|
||
return () => window.removeEventListener('resize', resizeCanvas)
|
||
}, [resizeCanvas])
|
||
|
||
useEffect(() => {
|
||
const video = videoRef.current
|
||
if (!video) return
|
||
const videoUrl = videoFile ? URL.createObjectURL(videoFile) : (videoId ? `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}` : '')
|
||
if (videoUrl) {
|
||
video.src = videoUrl
|
||
video.onloadeddata = resizeCanvas
|
||
video.onerror = () => setVideoError("Failed to load video")
|
||
}
|
||
return () => { if (videoFile) URL.revokeObjectURL(videoUrl) }
|
||
}, [videoFile, videoId, resizeCanvas])
|
||
|
||
const addDetectionLog = useCallback((frame: number, detections: any[]) => {
|
||
if (loggedFrames.current.has(frame)) return
|
||
loggedFrames.current.add(frame)
|
||
if (detections.length > 0) {
|
||
const coords = gpsMap.current.get(detections[0].pothole_id ?? detections[0].signboard_id ?? detections[0].detection_id)
|
||
if (coords) { setLastDetectedLat(coords.lat); setLastDetectedLng(coords.lng) }
|
||
}
|
||
setLogs(prev => [{
|
||
frame,
|
||
videoTime: formatVideoTime(frame, data.video_info.fps),
|
||
detections: detections.map(d => ({
|
||
id: d.pothole_id ?? d.signboard_id ?? d.detection_id,
|
||
type: d.type || d._detType,
|
||
bbox: d.bbox,
|
||
confidence: d.confidence,
|
||
latitude: gpsMap.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat,
|
||
longitude: gpsMap.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng,
|
||
}))
|
||
}, ...prev].slice(0, MAX_LOGS))
|
||
}, [data.video_info.fps, formatVideoTime])
|
||
|
||
useEffect(() => {
|
||
let animId: number
|
||
const update = () => {
|
||
if (videoRef.current && !videoRef.current.paused) {
|
||
const frame = Math.round(videoRef.current.currentTime * data.video_info.fps)
|
||
if (frame !== lastProcessedFrame.current) {
|
||
lastProcessedFrame.current = frame
|
||
setCurrentFrame(frame)
|
||
const dets = getNearestDetections(frame)
|
||
setDetectionsCount(dets?.length || 0)
|
||
drawBoundingBoxes(dets || [])
|
||
setCurrentFrameCounts(getStickyCounts(frame))
|
||
if (dets?.length) addDetectionLog(frame, dets)
|
||
}
|
||
}
|
||
animId = requestAnimationFrame(update)
|
||
}
|
||
animId = requestAnimationFrame(update)
|
||
return () => cancelAnimationFrame(animId)
|
||
}, [data.video_info.fps, addDetectionLog, drawBoundingBoxes, getNearestDetections, getStickyCounts])
|
||
|
||
useEffect(() => {
|
||
const v = videoRef.current
|
||
if (!v) return
|
||
v.onended = () => { if (!hasPlayedOnce) { setHasPlayedOnce(true); setShowSummary(true) } }
|
||
v.onseeked = () => {
|
||
const frame = Math.round(v.currentTime * data.video_info.fps)
|
||
setCurrentFrame(frame)
|
||
const dets = frameDetectionMap.current.get(frame) || []
|
||
drawBoundingBoxes(dets)
|
||
setCurrentFrameCounts(getStickyCounts(frame))
|
||
}
|
||
}, [data.video_info.fps, drawBoundingBoxes, getStickyCounts, hasPlayedOnce])
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<Card className="overflow-hidden">
|
||
<CardHeader className="pb-4 border-b">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded bg-secondary">
|
||
<Film className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-lg font-bold">Detection Playback</CardTitle>
|
||
<CardDescription className="text-xs">Real-time object detection analysis</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-6">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-2 space-y-6">
|
||
{videoError && <Badge variant="destructive" className="mb-4">{videoError}</Badge>}
|
||
<div ref={containerRef} className="relative bg-black rounded-lg overflow-hidden border shadow-inner" style={{ aspectRatio: `${data.video_info.width}/${data.video_info.height}` }}>
|
||
<video ref={videoRef} controls className="w-full h-full" />
|
||
<canvas ref={canvasRef} className="absolute top-0 left-0 pointer-events-none w-full h-full" />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 py-3 px-6 bg-card border rounded-lg">
|
||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||
<span className="text-[11px] font-bold text-red-500 uppercase tracking-tight">POTHOLE:</span>
|
||
<span className="text-sm font-bold text-red-600">{currentFrameCounts.pothole}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||
<span className="text-[11px] font-bold text-blue-500 uppercase tracking-tight">DEFECT SIGN BOARD:</span>
|
||
<span className="text-sm font-bold text-blue-600">{currentFrameCounts.defected_sign_board}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||
<span className="text-[11px] font-bold text-indigo-500 uppercase tracking-tight">DAMAGE ROAD MARK:</span>
|
||
<span className="text-sm font-bold text-indigo-600">{currentFrameCounts.damaged_road_marking}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||
<span className="text-[11px] font-bold text-orange-500 uppercase tracking-tight">ROADCRACK:</span>
|
||
<span className="text-sm font-bold text-orange-600">{currentFrameCounts.road_crack}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-6 border-l pl-6 border-border/80 ml-auto">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">FRAME:</span>
|
||
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{currentFrame}</span>
|
||
</div>
|
||
{lastDetectedLat && (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">LAT:</span>
|
||
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{lastDetectedLat.toFixed(7)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">LNG:</span>
|
||
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{lastDetectedLng?.toFixed(7)}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-bold flex items-center gap-2">
|
||
<Activity className="h-4 w-4" />
|
||
Detection Logs
|
||
</h4>
|
||
<Badge variant="secondary" className="text-[10px] font-bold uppercase">Live Logs</Badge>
|
||
</div>
|
||
<ScrollArea className="h-[430px] rounded-lg border bg-muted/20 p-4">
|
||
<div className="space-y-3">
|
||
{logs.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||
<Film className="h-8 w-8 mb-2 opacity-20" />
|
||
<p className="text-xs">Playback to see logs</p>
|
||
</div>
|
||
) : (
|
||
logs.map((log, i) => (
|
||
<div key={i} onClick={() => seekToFrame(log.frame)} className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-bold">Frame: {log.frame}</span>
|
||
<span className="text-xs text-muted-foreground/80">{log.videoTime}</span>
|
||
</div>
|
||
<div className="space-y-4">
|
||
{log.detections.map((d, j) => (
|
||
<div key={j} className="space-y-1">
|
||
<div className="text-[13px] font-bold">
|
||
{(d.type || "").replace(/ /g, "_")} ID: {d.id} | Confidence: {(d.confidence * 100).toFixed(1)}%
|
||
</div>
|
||
{d.bbox && (
|
||
<div className="text-[12px] text-muted-foreground/80 font-medium">
|
||
Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}) <span className="text-muted-foreground/40">→</span> ({Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
|
||
</div>
|
||
)}
|
||
{d.latitude && (
|
||
<div className="text-[12px] text-muted-foreground/80 font-medium">
|
||
GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{showSummary && (
|
||
<div className="space-y-6 pt-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
|
||
<DetailedSummarySection projectId={projectId || ""} videoId={videoId} show={showSummary} detectionType={detectionType} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} |