From 7eb3d60932753b9779780c51b7f1587f7d3438c2 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Thu, 19 Mar 2026 10:21:38 +0530 Subject: [PATCH] refactor: video section --- next-env.d.ts | 2 +- package-lock.json | 15 + src/components/video-player-section.tsx | 997 +++--------------- .../video/detailed-summary-section.tsx | 208 ++++ src/components/video/detection-logs.tsx | 74 ++ src/components/video/detection-stats-bar.tsx | 92 ++ src/components/video/summary-section.tsx | 128 +++ src/components/video/video-canvas-player.tsx | 132 +++ src/hooks/use-cumulative-counts.ts | 71 ++ src/hooks/use-frame-detection-map.ts | 70 ++ src/hooks/use-gps-map.ts | 27 + src/hooks/use-video-detection-loop.ts | 34 + src/types/chainage.ts | 39 + src/types/detection.ts | 21 + src/types/video.ts | 2 +- src/utils/canvas-drawing.ts | 57 + 16 files changed, 1115 insertions(+), 854 deletions(-) create mode 100644 src/components/video/detailed-summary-section.tsx create mode 100644 src/components/video/detection-logs.tsx create mode 100644 src/components/video/detection-stats-bar.tsx create mode 100644 src/components/video/summary-section.tsx create mode 100644 src/components/video/video-canvas-player.tsx create mode 100644 src/hooks/use-cumulative-counts.ts create mode 100644 src/hooks/use-frame-detection-map.ts create mode 100644 src/hooks/use-gps-map.ts create mode 100644 src/hooks/use-video-detection-loop.ts create mode 100644 src/utils/canvas-drawing.ts diff --git a/next-env.d.ts b/next-env.d.ts index 1511519..20e7bcf 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index d3e1fb2..345996c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10110,6 +10110,21 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", + "integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/src/components/video-player-section.tsx b/src/components/video-player-section.tsx index 449f96d..cfe269b 100644 --- a/src/components/video-player-section.tsx +++ b/src/components/video-player-section.tsx @@ -1,404 +1,51 @@ 'use client'; -import { useEffect, useRef, useState, useCallback } from 'react'; -import dynamic from 'next/dynamic'; +import { useMemo, useState, useCallback, useRef, useReducer, useEffect } from 'react'; 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'; +import { Film } 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, DetectionCounts, DetectionLogEntry } from '@/types'; +import { useGpsMap } from '@/hooks/use-gps-map'; +import { useFrameDetectionMap } from '@/hooks/use-frame-detection-map'; +import { useCumulativeCounts } from '@/hooks/use-cumulative-counts'; +import { useVideoDetectionLoop } from '@/hooks/use-video-detection-loop'; -import { DetectionData, DetectionType } from '@/types'; -import { cn } from '@/lib/utils'; - -import { projectService } from '@/services/api'; +import VideoCanvasPlayer, { VideoCanvasPlayerRef } from './video/video-canvas-player'; +import DetectionStatsBar from './video/detection-stats-bar'; +import DetectionLogs from './video/detection-logs'; +import SummarySection from './video/summary-section'; +import DetailedSummarySection from './video/detailed-summary-section'; type VideoPlayerSectionProps = { data: DetectionData; videoId: string; - videoFile: File | null; // null when loading from server (results page) + videoFile: File | null; detectionType: DetectionType; - projectId?: string; // Optional project ID for fetching detailed summary + projectId?: string; }; -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) -}; +const MAX_LOGS = 50; -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; - }; - }>; - }; - }; - }; - }; -}; +type LogAction = { type: 'ADD_LOG'; payload: DetectionLogEntry } | { type: 'CLEAR' }; -function DetailedSummarySection({ - projectId, - videoId, - show, - detectionType, -}: { - projectId: string; - videoId: string; - show: boolean; - detectionType: DetectionType; -}) { - const [summaryData, setSummaryData] = useState(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 ( -
- - -
-
-
- -
-
- Chainages - - {isCombined ? 'Potholes & Signboards' : isPothole ? 'Potholes' : 'Signboards'}{' '} - detected - -
-
- -
-
- - -
- {/* Project Info */} -
-
- - Project - - {summaryData.project.name} -
- {summaryData.project.corridor_name && ( -
- - Corridor - - - {summaryData.project.corridor_name} - -
- )} -
- - {/* Packages and Chainages */} - {Object.entries(summaryData.packages).map(([packageName, packageData]) => ( -
-
- {packageName} -
-
- {Object.entries(packageData.chainages).map(([chainageName, chainageData]) => ( -
-
{chainageName}
-
- {chainageData.detection_count} -
-
- ))} -
-
- ))} -
-
-
-
- - - -
-
- -
-
- All Detections - - Complete list with GPS coordinates - -
-
-
- - -
- {allDetections.map(({ detection, chainageName }, idx) => ( -
-
- - {detection.type === 'pothole' - ? 'Pothole' - : (detection.class || detection.type || '').replace(/_/g, ' ')}{' '} - #{detection.id} - - - Frame {detection.frame_number} - -
-
-
- Chainage: {chainageName} -
-
- Confidence:{' '} - - {(detection.confidence * 100).toFixed(1)}% - -
-
- GPS: {detection.latitude}, {detection.longitude} -
-
-
- ))} -
-
-
-
- - {/* Map Modal */} - setShowMap(false)} - detections={allDetections.map(({ detection }) => detection)} - detectionType={detectionType} - /> -
- ); +function logsReducer(state: DetectionLogEntry[], action: LogAction): DetectionLogEntry[] { + switch (action.type) { + case 'ADD_LOG': + if (state.length > 0 && state[0].frame === action.payload.frame) return state; + return [action.payload, ...state].slice(0, MAX_LOGS); + case 'CLEAR': + return []; + default: + return state; + } } -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 ( - - -
-
- -
-
- Quick Stats - Detection analysis overview -
-
-
- -
- {stats.map((stat) => { - const Icon = stat.icon; - return ( -
-
- -
-
{stat.value}
-
- {stat.label} -
-
- ); - })} -
-
-
- ); -} +const formatVideoTime = (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')}`; +}; export default function VideoPlayerSection({ data, @@ -407,18 +54,20 @@ export default function VideoPlayerSection({ detectionType, projectId, }: VideoPlayerSectionProps) { + // Refs + const playerRef = useRef(null); const videoRef = useRef(null); const canvasRef = useRef(null); - const containerRef = useRef(null); + const loggedFrames = useRef>(new Set()); + + // State const [currentFrame, setCurrentFrame] = useState(0); - const [detectionsCount, setDetectionsCount] = useState(0); - const [logs, setLogs] = useState([]); const [showSummary, setShowSummary] = useState(false); const [hasPlayedOnce, setHasPlayedOnce] = useState(false); const [videoError, setVideoError] = useState(null); const [lastDetectedLat, setLastDetectedLat] = useState(null); const [lastDetectedLng, setLastDetectedLng] = useState(null); - const [currentFrameCounts, setCurrentFrameCounts] = useState>({ + const [currentFrameCounts, setCurrentFrameCounts] = useState({ defected_sign_board: 0, pothole: 0, road_crack: 0, @@ -426,48 +75,101 @@ export default function VideoPlayerSection({ good_sign_board: 0, }); - const frameDetectionMap = useRef>(new Map()); - const lastProcessedFrame = useRef(-1); - const loggedFrames = useRef>(new Set()); - const cumulativeCountsMap = useRef>>(new Map()); - const sortedFrameIndices = useRef([]); - const detectedFrameSkip = useRef(3); - const MAX_LOGS = 50; + // Logs Reducer + const [logs, dispatchLogs] = useReducer(logsReducer, []); - const isCombined = detectionType === 'pot-sign-detection'; - const isPothole = detectionType === 'pothole-detection' || isCombined; - const isSignboard = detectionType === 'sign-board-detection' || isCombined; + // Custom Hooks + const gpsMap = useGpsMap(data); + const { getNearestDetections } = useFrameDetectionMap(data, detectionType); + const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(data.frames); - const gpsMap = useRef>(new Map()); + // Memoized video URL + const videoUrl = useMemo(() => { + if (videoFile) return URL.createObjectURL(videoFile); + if (videoId) return `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}`; + return ''; + }, [videoFile, videoId]); + // Clean up Object URL 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 }); - } - }); + return () => { + if (videoFile && videoUrl) URL.revokeObjectURL(videoUrl); }; - 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]); + }, [videoFile, videoUrl]); - 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')}`; + // Handle detection updates + const handleFrameUpdate = useCallback( + (frame: number) => { + setCurrentFrame(frame); + + // Optimized lookup + const dets = getNearestDetections(frame, sortedFrameIndices); + const counts = getStickyCounts(frame); + + // Update visuals via ref to avoid state-induced slow re-renders in heavy loops + playerRef.current?.drawDetections(dets || []); + + // Frame stats + setCurrentFrameCounts(counts); + + // Logging logic (throttled/batched by frame ID) + if (dets && dets.length > 0 && !loggedFrames.current.has(frame)) { + loggedFrames.current.add(frame); + + const firstDetId = dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id; + const coords = gpsMap.get(firstDetId); + + if (coords) { + setLastDetectedLat(coords.lat); + setLastDetectedLng(coords.lng); + } + + dispatchLogs({ + type: 'ADD_LOG', + payload: { + frame, + videoTime: formatVideoTime(frame, data.video_info.fps), + detections: dets.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.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat, + longitude: gpsMap.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng, + })), + }, + }); + } + }, + [data.video_info.fps, getNearestDetections, getStickyCounts, gpsMap, sortedFrameIndices], + ); + + // Playback loop hook + useVideoDetectionLoop(videoRef, data.video_info.fps, handleFrameUpdate); + + // Callbacks + const handleLoadedData = useCallback(() => { + playerRef.current?.resize(); }, []); + const handleVideoError = useCallback(() => { + setVideoError('Failed to load video'); + }, []); + + const handleVideoEnded = useCallback(() => { + if (!hasPlayedOnce) { + setHasPlayedOnce(true); + setShowSummary(true); + } + }, [hasPlayedOnce]); + + const handleVideoSeeked = useCallback(() => { + const video = videoRef.current; + if (!video) return; + const frame = Math.round(video.currentTime * data.video_info.fps); + handleFrameUpdate(frame); + }, [data.video_info.fps, handleFrameUpdate]); + const seekToFrame = useCallback( (frame: number) => { const video = videoRef.current; @@ -477,296 +179,6 @@ export default function VideoPlayerSection({ [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>(); - 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 = { - 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 (
@@ -786,148 +198,29 @@ export default function VideoPlayerSection({
- {videoError && ( - - {videoError} - - )} -
-
+ -
-
-
- - POTHOLE: - - - {currentFrameCounts.pothole} - -
-
- - DEFECT SIGN BOARD: - - - {currentFrameCounts.defected_sign_board} - -
-
- - DAMAGE ROAD MARK: - - - {currentFrameCounts.damaged_road_marking} - -
-
- - ROADCRACK: - - - {currentFrameCounts.road_crack} - -
-
- -
-
- - FRAME: - - - {currentFrame} - -
- {lastDetectedLat && ( - <> -
- - LAT: - - - {lastDetectedLat.toFixed(7)} - -
-
- - LNG: - - - {lastDetectedLng?.toFixed(7)} - -
- - )} -
-
+
-
-
-

- - Detection Logs -

- - Live Logs - -
- -
- {logs.length === 0 ? ( -
- -

Playback to see logs

-
- ) : ( - logs.map((log, i) => ( -
seekToFrame(log.frame)} - className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group" - > -
- Frame: {log.frame} - {log.videoTime} -
-
- {log.detections.map((d, j) => ( -
-
- {(d.type || '').replace(/ /g, '_')} ID: {d.id} | Confidence:{' '} - {(d.confidence * 100).toFixed(1)}% -
- {d.bbox && ( -
- Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}){' '} - ( - {Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)}) -
- )} - {d.latitude && ( -
- GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)} -
- )} -
- ))} -
-
- )) - )} -
-
-
+
diff --git a/src/components/video/detailed-summary-section.tsx b/src/components/video/detailed-summary-section.tsx new file mode 100644 index 0000000..79f825d --- /dev/null +++ b/src/components/video/detailed-summary-section.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { Map as MapIcon, Activity } from 'lucide-react'; +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 { DetectionType, ChainageSummaryData } from '@/types'; +import { projectService } from '@/services/api'; + +// Dynamically import MapModal with SSR disabled (Leaflet requires window object) +const MapModal = dynamic(() => import('@/components/map-modal'), { ssr: false }); + +interface DetailedSummarySectionProps { + projectId: string; + videoId: string; + show: boolean; + detectionType: DetectionType; +} + +const DetailedSummarySection = ({ + projectId, + videoId, + show, + detectionType, +}: DetailedSummarySectionProps) => { + const [summaryData, setSummaryData] = useState(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 ( +
+ + +
+
+
+ +
+
+ Chainages + + {isCombined ? 'Potholes & Signboards' : isPothole ? 'Potholes' : 'Signboards'}{' '} + detected + +
+
+ +
+
+ + +
+ {/* Project Info */} +
+
+ + Project + + {summaryData.project.name} +
+ {summaryData.project.corridor_name && ( +
+ + Corridor + + + {summaryData.project.corridor_name} + +
+ )} +
+ + {/* Packages and Chainages */} + {Object.entries(summaryData.packages).map(([packageName, packageData]) => ( +
+
+ {packageName} +
+
+ {Object.entries(packageData.chainages).map(([chainageName, chainageData]) => ( +
+
{chainageName}
+
+ {chainageData.detection_count} +
+
+ ))} +
+
+ ))} +
+
+
+
+ + + +
+
+ +
+
+ All Detections + + Complete list with GPS coordinates + +
+
+
+ + +
+ {allDetections.map(({ detection, chainageName }, idx) => ( +
+
+ + {detection.type === 'pothole' + ? 'Pothole' + : (detection.class || detection.type || '').replace(/_/g, ' ')}{' '} + #{detection.id} + + + Frame {detection.frame_number} + +
+
+
+ Chainage: {chainageName} +
+
+ Confidence:{' '} + + {(detection.confidence * 100).toFixed(1)}% + +
+
+ GPS: {detection.latitude}, {detection.longitude} +
+
+
+ ))} +
+
+
+
+ + {/* Map Modal */} + setShowMap(false)} + detections={allDetections.map(({ detection }) => detection)} + detectionType={detectionType} + /> +
+ ); +}; + +export default DetailedSummarySection; diff --git a/src/components/video/detection-logs.tsx b/src/components/video/detection-logs.tsx new file mode 100644 index 0000000..01844eb --- /dev/null +++ b/src/components/video/detection-logs.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Activity, Film } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { DetectionLogEntry } from '@/types'; + +interface DetectionLogsProps { + logs: DetectionLogEntry[]; + onSeek: (frame: number) => void; +} + +const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => { + return ( +
+
+

+ + Detection Logs +

+ + Live Logs + +
+ +
+ {logs.length === 0 ? ( +
+ +

Playback to see logs

+
+ ) : ( + logs.map((log, i) => ( +
onSeek(log.frame)} + className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group" + > +
+ Frame: {log.frame} + {log.videoTime} +
+
+ {log.detections.map((d, j) => ( +
+
+ {(d.type || '').replace(/ /g, '_')} ID: {d.id} | Confidence:{' '} + {(d.confidence * 100).toFixed(1)}% +
+ {d.bbox && ( +
+ Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}){' '} + ( + {Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)}) +
+ )} + {d.latitude && ( +
+ GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)} +
+ )} +
+ ))} +
+
+ )) + )} +
+
+
+ ); +}; + +export default DetectionLogs; diff --git a/src/components/video/detection-stats-bar.tsx b/src/components/video/detection-stats-bar.tsx new file mode 100644 index 0000000..7618a3d --- /dev/null +++ b/src/components/video/detection-stats-bar.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { memo } from 'react'; +import { DetectionCounts } from '@/types'; + +interface DetectionStatsBarProps { + currentFrameCounts: DetectionCounts; + currentFrame: number; + lastDetectedLat: number | null; + lastDetectedLng: number | null; +} + +const DetectionStatsBar = memo( + ({ + currentFrameCounts, + currentFrame, + lastDetectedLat, + lastDetectedLng, + }: DetectionStatsBarProps) => { + return ( +
+
+
+ + POTHOLE: + + {currentFrameCounts.pothole} +
+
+ + DEFECT SIGN BOARD: + + + {currentFrameCounts.defected_sign_board} + +
+
+ + DAMAGE ROAD MARK: + + + {currentFrameCounts.damaged_road_marking} + +
+
+ + ROADCRACK: + + + {currentFrameCounts.road_crack} + +
+
+ +
+
+ + FRAME: + + + {currentFrame} + +
+ {lastDetectedLat && ( + <> +
+ + LAT: + + + {lastDetectedLat.toFixed(7)} + +
+
+ + LNG: + + + {lastDetectedLng?.toFixed(7)} + +
+ + )} +
+
+ ); + }, +); + +DetectionStatsBar.displayName = 'DetectionStatsBar'; + +export default DetectionStatsBar; diff --git a/src/components/video/summary-section.tsx b/src/components/video/summary-section.tsx new file mode 100644 index 0000000..515bd73 --- /dev/null +++ b/src/components/video/summary-section.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { Target, SignpostBig, AlertTriangle, Activity, Gauge, Monitor, Film } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DetectionData, DetectionType } from '@/types'; +import { cn } from '@/lib/utils'; + +interface SummarySectionProps { + data: DetectionData; + show: boolean; + detectionType: DetectionType; +} + +const SummarySection = ({ data, show, detectionType }: SummarySectionProps) => { + if (!show) return null; + + 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 ( + + +
+
+ +
+
+ Quick Stats + Detection analysis overview +
+
+
+ +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ +
+
{stat.value}
+
+ {stat.label} +
+
+ ); + })} +
+
+
+ ); +}; + +export default SummarySection; diff --git a/src/components/video/video-canvas-player.tsx b/src/components/video/video-canvas-player.tsx new file mode 100644 index 0000000..c00f1c9 --- /dev/null +++ b/src/components/video/video-canvas-player.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { drawBoundingBoxes } from '@/utils/canvas-drawing'; + +interface VideoCanvasPlayerProps { + videoRef: React.RefObject; + canvasRef: React.RefObject; + videoUrl: string; + videoWidth: number; + videoHeight: number; + videoError: string | null; + onLoadedData: () => void; + onEnded: () => void; + onSeeked: () => void; + currentDetections: any[]; +} + +export interface VideoCanvasPlayerRef { + resize: () => void; + drawDetections: (detections: any[]) => void; +} + +const VideoCanvasPlayer = forwardRef( + ( + { + videoRef, + canvasRef, + videoUrl, + videoWidth, + videoHeight, + videoError, + onLoadedData, + onEnded, + onSeeked, + currentDetections, + }, + ref, + ) => { + const containerRef = useRef(null); + + const detectionsRef = useRef(currentDetections); + useEffect(() => { + detectionsRef.current = currentDetections; + }, [currentDetections]); + + const resize = useCallback(() => { + if (!videoRef.current || !canvasRef.current) return; + const rect = videoRef.current.getBoundingClientRect(); + canvasRef.current.width = rect.width; + canvasRef.current.height = rect.height; + + // Draw immediately on resize + const ctx = canvasRef.current.getContext('2d'); + if (ctx) { + drawBoundingBoxes( + ctx, + detectionsRef.current, + canvasRef.current.width, + canvasRef.current.height, + videoWidth, + videoHeight, + ); + } + }, [videoRef, canvasRef, videoWidth, videoHeight]); + + useImperativeHandle(ref, () => ({ + resize, + drawDetections: (detections) => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext('2d'); + if (ctx) { + drawBoundingBoxes( + ctx, + detections, + canvasRef.current.width, + canvasRef.current.height, + videoWidth, + videoHeight, + ); + } + }, + })); + + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver(() => { + resize(); + }); + + observer.observe(containerRef.current); + + return () => observer.disconnect(); + }, [resize]); + // Reacting to detections to ensure correct draw on resize + + return ( +
+ {videoError && ( + + {videoError} + + )} +
+
+
+ ); + }, +); + +VideoCanvasPlayer.displayName = 'VideoCanvasPlayer'; + +export default VideoCanvasPlayer; diff --git a/src/hooks/use-cumulative-counts.ts b/src/hooks/use-cumulative-counts.ts new file mode 100644 index 0000000..4632f5f --- /dev/null +++ b/src/hooks/use-cumulative-counts.ts @@ -0,0 +1,71 @@ +import { useMemo, useCallback } from 'react'; +import { DetectionCounts } from '@/types'; + +const DEFAULT_COUNTS: DetectionCounts = { + defected_sign_board: 0, + pothole: 0, + road_crack: 0, + damaged_road_marking: 0, + good_sign_board: 0, +}; + +export const useCumulativeCounts = (frames: any[]) => { + const result = useMemo(() => { + const map = new Map(); + let lastCounts: DetectionCounts = { ...DEFAULT_COUNTS }; + let indices: number[] = []; + + if (frames && Array.isArray(frames)) { + const sortedFrames = [...frames].sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0)); + 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 || { ...DEFAULT_COUNTS }; + 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 }); + }); + } + return { map, indices }; + }, [frames]); + + const getStickyCounts = useCallback( + (frameNumber: number) => { + const { map, indices } = result; + if (indices.length === 0) return DEFAULT_COUNTS; + + let targetFrameId = -1; + let low = 0, + high = indices.length - 1; + + while (low <= high) { + let mid = Math.floor((low + high) / 2); + if (indices[mid] <= frameNumber) { + targetFrameId = indices[mid]; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return targetFrameId !== -1 ? map.get(targetFrameId) || DEFAULT_COUNTS : DEFAULT_COUNTS; + }, + [result], + ); + + return { getStickyCounts, sortedFrameIndices: result.indices }; +}; diff --git a/src/hooks/use-frame-detection-map.ts b/src/hooks/use-frame-detection-map.ts new file mode 100644 index 0000000..3b3bacc --- /dev/null +++ b/src/hooks/use-frame-detection-map.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import { DetectionData, DetectionType } from '@/types'; + +export const useFrameDetectionMap = (data: DetectionData, detectionType: DetectionType) => { + const frameDetectionMap = useMemo(() => { + const map = new Map(); + const isCombined = detectionType === 'pot-sign-detection'; + const isPothole = detectionType === 'pothole-detection' || isCombined; + const isSignboard = detectionType === 'sign-board-detection' || isCombined; + + 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 as any).potholes) { + detections = [ + ...detections, + ...(frameData as any).potholes.map((p: any) => ({ ...p, _detType: 'pothole' })), + ]; + } + if (isSignboard && (frameData as any).signboards) { + detections = [ + ...detections, + ...(frameData as any).signboards.map((s: any) => ({ ...s, _detType: 'signboard' })), + ]; + } + if (detections.length > 0) map.set(frameId, detections); + } + }); + } + return map; + }, [data, detectionType]); + + const getNearestDetections = (frame: number, sortedFrameIndices: number[], maxSkip = 3) => { + const exact = frameDetectionMap.get(frame); + if (exact) return exact; + + let low = 0, + high = sortedFrameIndices.length - 1, + targetIndex = -1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (sortedFrameIndices[mid] <= frame) { + targetIndex = sortedFrameIndices[mid]; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return targetIndex !== -1 && frame - targetIndex <= maxSkip + ? frameDetectionMap.get(targetIndex) + : undefined; + }; + + return { frameDetectionMap, getNearestDetections }; +}; diff --git a/src/hooks/use-gps-map.ts b/src/hooks/use-gps-map.ts new file mode 100644 index 0000000..a183d73 --- /dev/null +++ b/src/hooks/use-gps-map.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; +import { DetectionData } from '@/types'; + +export const useGpsMap = (data: DetectionData) => { + const gpsMap = useMemo(() => { + 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); + return map; + }, [data]); + + return gpsMap; +}; diff --git a/src/hooks/use-video-detection-loop.ts b/src/hooks/use-video-detection-loop.ts new file mode 100644 index 0000000..794a5e0 --- /dev/null +++ b/src/hooks/use-video-detection-loop.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; + +export const useVideoDetectionLoop = ( + videoRef: React.RefObject, + fps: number, + onFrameUpdate: (frame: number) => void, +) => { + const lastProcessedFrame = useRef(-1); + const animId = useRef(-1); + + useEffect(() => { + const update = () => { + const video = videoRef.current; + if (video && !video.paused) { + const frame = Math.round(video.currentTime * fps); + if (frame !== lastProcessedFrame.current) { + lastProcessedFrame.current = frame; + onFrameUpdate(frame); + } + } + animId.current = requestAnimationFrame(update); + }; + + animId.current = requestAnimationFrame(update); + + return () => { + if (animId.current !== -1) { + cancelAnimationFrame(animId.current); + } + }; + }, [fps, onFrameUpdate, videoRef]); + + return { lastProcessedFrame }; +}; diff --git a/src/types/chainage.ts b/src/types/chainage.ts index 3559a06..44ab7a1 100644 --- a/src/types/chainage.ts +++ b/src/types/chainage.ts @@ -38,3 +38,42 @@ export interface ChainageUpdate { end_lng?: number; direction?: 'UP' | 'DOWN'; } + +export 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; + }; + }>; + }; + }; + }; + }; +}; diff --git a/src/types/detection.ts b/src/types/detection.ts index 319b529..7b9b33a 100644 --- a/src/types/detection.ts +++ b/src/types/detection.ts @@ -27,3 +27,24 @@ export interface DetectionListItem { lat?: number; lng?: number; } + +export interface DetectionCounts { + defected_sign_board: number; + pothole: number; + road_crack: number; + damaged_road_marking: number; + good_sign_board: number; +} + +export type DetectionLogEntry = { + frame: number; + detections: Array<{ + id: number; + type?: string; + bbox: { x1: number; y1: number; x2: number; y2: number }; + confidence: number; + latitude?: number; + longitude?: number; + }>; + videoTime: string; +}; diff --git a/src/types/video.ts b/src/types/video.ts index 9972c2b..be6294c 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -19,5 +19,5 @@ export interface Video { export interface VideoResultData { videoId: string; - detectionType: string; + detectionType?: string; } diff --git a/src/utils/canvas-drawing.ts b/src/utils/canvas-drawing.ts new file mode 100644 index 0000000..60375ca --- /dev/null +++ b/src/utils/canvas-drawing.ts @@ -0,0 +1,57 @@ +export const DETECTION_COLORS: Record = { + pothole: '#ef4444', + defected_sign_board: '#3b82f6', + road_crack: '#f59e0b', + damaged_road_marking: '#6366f1', + good_sign_board: '#10b981', +}; + +export const drawBoundingBoxes = ( + ctx: CanvasRenderingContext2D, + detections: any[], + canvasWidth: number, + canvasHeight: number, + videoWidth: number, + videoHeight: number, +) => { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (!detections || detections.length === 0) return; + + const scaleX = canvasWidth / videoWidth; + const scaleY = canvasHeight / videoHeight; + + detections.forEach((detection) => { + const bbox = detection.bbox; + if (!bbox) return; + + const x1 = bbox.x1 * scaleX; + const y1 = bbox.y1 * scaleY; + const x2 = bbox.x2 * scaleX; + const y2 = bbox.y2 * scaleY; + + const type = (detection.type || detection._detType || '').toLowerCase(); + const boxColor = DETECTION_COLORS[type] || '#3b82f6'; + + ctx.strokeStyle = boxColor; + ctx.lineWidth = 3; + ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); + + // Transparent fill + 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); + + // Label background + ctx.fillStyle = boxColor; + ctx.fillRect(x1, y1 - 20, metrics.width + 10, 20); + + // Label text + ctx.fillStyle = '#fff'; + ctx.fillText(label, x1 + 5, y1 - 6); + }); +};