@@ -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);
+ });
+};