feat(video): base of video refactoring
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,74 +1,56 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
import VideoPlayerSection from '@/components/video-player-section';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { sessionService, videoService } from '@/services/api';
|
import { sessionService } from '@/services/api';
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
import { SessionContext, CompletedVideoResult, DetectionType } from '@/types';
|
||||||
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
import { clearVideoFile } from '@/lib/video-storage';
|
||||||
import { ROUTES } from '@/utils/routes';
|
import { ROUTES } from '@/utils/routes';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
||||||
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
||||||
|
if (data.detection_mode) return data.detection_mode as DetectionType;
|
||||||
|
|
||||||
|
const signboards = data.summary?.unique_signboards || 0;
|
||||||
|
const potholes = data.summary?.unique_potholes || 0;
|
||||||
|
|
||||||
|
if (signboards > 0 && potholes > 0) return 'pot-sign-detection';
|
||||||
|
if (signboards > 0) return 'sign-board-detection';
|
||||||
|
return 'pothole-detection';
|
||||||
|
};
|
||||||
|
|
||||||
export default function VideoResultsPage() {
|
export default function VideoResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { videoId } = useParams() as { videoId: string };
|
const { videoId } = useParams() as { videoId: string };
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
const [session, setSession] = useState<SessionContext | null>(null);
|
||||||
const [detectionData, setDetectionData] = useState<DetectionData | null>(
|
|
||||||
null,
|
const {
|
||||||
|
data: detectionData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error: queryError,
|
||||||
|
} = useVideoResultsQuery(videoId);
|
||||||
|
|
||||||
|
const detectionType = useMemo<DetectionType>(
|
||||||
|
() => (detectionData ? inferDetectionType(detectionData) : 'pothole-detection'),
|
||||||
|
[detectionData],
|
||||||
);
|
);
|
||||||
const [detectionType, setDetectionType] =
|
|
||||||
useState<DetectionType>('pothole-detection');
|
const error = isError
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
? queryError instanceof Error
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
? queryError.message
|
||||||
const [error, setError] = useState<string | null>(null);
|
: 'Failed to load results'
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedSession = sessionService.loadSession();
|
setSession(sessionService.loadSession());
|
||||||
setSession(storedSession);
|
}, []);
|
||||||
|
|
||||||
const fetchResults = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch detection data from backend
|
|
||||||
const data = await videoService.getVideoResults(videoId);
|
|
||||||
setDetectionData(data as any);
|
|
||||||
|
|
||||||
// Try to infer detection type from results if possible
|
|
||||||
if (data.detection_mode) {
|
|
||||||
setDetectionType(data.detection_mode);
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_signboards !== undefined &&
|
|
||||||
data.summary?.unique_signboards > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('sign-board-detection');
|
|
||||||
} else if (
|
|
||||||
data.summary?.unique_potholes !== undefined &&
|
|
||||||
data.summary?.unique_potholes > 0
|
|
||||||
) {
|
|
||||||
setDetectionType('pothole-detection');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve video file from IndexedDB
|
|
||||||
const storedVideoFile = await getVideoFile(videoId);
|
|
||||||
if (storedVideoFile) {
|
|
||||||
setVideoFile(storedVideoFile);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load results');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videoId) {
|
|
||||||
fetchResults();
|
|
||||||
}
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
const handleNewAnalysis = async () => {
|
const handleNewAnalysis = async () => {
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
@@ -188,7 +170,6 @@ export default function VideoResultsPage() {
|
|||||||
<VideoPlayerSection
|
<VideoPlayerSection
|
||||||
data={detectionData}
|
data={detectionData}
|
||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
detectionType={detectionType}
|
||||||
projectId={session?.projectId || undefined}
|
projectId={session?.projectId || undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { videoService } from '@/services/api';
|
||||||
|
|
||||||
|
import { videoKeys } from '../queries/videoKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the completed detection results for a video.
|
||||||
|
*/
|
||||||
|
export function useVideoResultsQuery(videoId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: videoKeys.result(videoId ?? ''),
|
||||||
|
queryFn: () => videoService.getVideoResults(videoId as string),
|
||||||
|
enabled: Boolean(videoId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the annotated video through the authenticated axios client and
|
||||||
|
* expose a local object URL that the native `<video>` element can play.
|
||||||
|
*
|
||||||
|
* Fetching the blob instead of pointing `<video src>` at the protected endpoint
|
||||||
|
* directly is what fixes the 401. The browser cannot attach the Bearer token to
|
||||||
|
* a media request, but axios can.
|
||||||
|
*/
|
||||||
|
export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||||
|
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||||
|
enabled: Boolean(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.data) {
|
||||||
|
setObjectUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = URL.createObjectURL(query.data);
|
||||||
|
setObjectUrl(nextUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(nextUrl);
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoUrl: objectUrl,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isError: query.isError,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,68 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Loader2, TrendingUp } from 'lucide-react';
|
|
||||||
import VideoPlayerSection from '@/components/video-player-section';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
|
||||||
import { sessionService } from '@/services/api';
|
|
||||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
|
||||||
import { clearVideoFile } from '@/lib/video-storage';
|
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { sessionService } from '@/services/api';
|
||||||
|
import { ROUTES } from '@/utils/routes';
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
|
||||||
const [detectionData] = useState<DetectionData | null>(null);
|
|
||||||
const [detectionType] = useState<DetectionType>('pothole-detection');
|
|
||||||
const [videoId] = useState<string | null>(null);
|
|
||||||
const [videoFile] = useState<File | null>(null);
|
|
||||||
const [isLoading] = useState(true);
|
|
||||||
const [error] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load session and video data on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedSession = sessionService.loadSession();
|
const storedSession = sessionService.loadSession();
|
||||||
const videoData = sessionService.loadVideoData();
|
const videoData = sessionService.loadVideoData();
|
||||||
|
|
||||||
if (!sessionService.isSessionValid(storedSession) || !videoData) {
|
if (!sessionService.isSessionValid(storedSession) || !videoData?.videoId) {
|
||||||
router.replace(ROUTES.UPLOAD);
|
router.replace(ROUTES.UPLOAD);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a videoId, redirect to the dynamic results page
|
|
||||||
if (videoData.videoId) {
|
|
||||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSession(storedSession);
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleNewAnalysis = async () => {
|
|
||||||
// Clear video from IndexedDB
|
|
||||||
if (videoId) {
|
|
||||||
try {
|
|
||||||
await clearVideoFile(videoId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to clear video file:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionService.clearSession();
|
|
||||||
router.push(ROUTES.UPLOAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
if (detectionType === 'pothole-detection')
|
|
||||||
return 'Pothole Detection Results';
|
|
||||||
if (detectionType === 'sign-board-detection')
|
|
||||||
return 'Signboard Detection Results';
|
|
||||||
return 'Pothole & Signboard Detection Results';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<Card className="flex flex-col items-center gap-4 p-8">
|
<Card className="flex flex-col items-center gap-4 p-8">
|
||||||
@@ -71,85 +30,4 @@ export default function ResultsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
|
||||||
<p className="text-destructive font-medium">{error}</p>
|
|
||||||
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<main className="min-h-screen">
|
|
||||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
|
||||||
<div className="mb-8">
|
|
||||||
<PageHeader
|
|
||||||
title={getTitle()}
|
|
||||||
description="View your AI-powered road analysis results"
|
|
||||||
icon={TrendingUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-bold leading-tight">
|
|
||||||
{session.projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Package
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.packageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
|
||||||
Segment
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
|
||||||
{session.chainageName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleNewAnalysis}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="font-semibold px-6 shrink-0 h-9"
|
|
||||||
>
|
|
||||||
Start New Analysis
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detectionData && videoId && (
|
|
||||||
<VideoPlayerSection
|
|
||||||
data={detectionData}
|
|
||||||
videoId={videoId}
|
|
||||||
videoFile={videoFile}
|
|
||||||
detectionType={detectionType}
|
|
||||||
projectId={session?.projectId || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const videoKeys = {
|
||||||
|
all: ['videos'] as const,
|
||||||
|
results: () => [...videoKeys.all, 'results'] as const,
|
||||||
|
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||||
|
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||||
|
annotatedVideo: (url: string) =>
|
||||||
|
[...videoKeys.annotatedVideos(), url] as const,
|
||||||
|
};
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
useMemo,
|
Activity,
|
||||||
useState,
|
AlertTriangle,
|
||||||
useCallback,
|
Clock,
|
||||||
useRef,
|
Film,
|
||||||
useReducer,
|
Gauge,
|
||||||
useEffect,
|
Loader2,
|
||||||
} from 'react';
|
MapPin,
|
||||||
|
SignpostBig,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -15,314 +18,112 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Film } from 'lucide-react';
|
import { CompletedVideoResult, DetectionResultLog } from '@/types';
|
||||||
|
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
|
||||||
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 VideoCanvasPlayer, {
|
|
||||||
VideoCanvasPlayerRef,
|
|
||||||
} from './video/video-canvas-player';
|
|
||||||
import DetectionStatsBar from './video/detection-stats-bar';
|
|
||||||
import DetectionLogs from './video/detection-logs';
|
import DetectionLogs from './video/detection-logs';
|
||||||
import SummarySection from './video/summary-section';
|
|
||||||
import DetailedSummarySection from './video/detailed-summary-section';
|
import DetailedSummarySection from './video/detailed-summary-section';
|
||||||
import {
|
|
||||||
getDetectionModeConfig,
|
|
||||||
getEnabledDetectionTypes,
|
|
||||||
} from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
type VideoPlayerSectionProps = {
|
type VideoPlayerSectionProps = {
|
||||||
data: DetectionData;
|
data: CompletedVideoResult;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
videoFile: File | null;
|
|
||||||
detectionType: string;
|
detectionType: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_LOGS = 50;
|
const formatDuration = (seconds: number) => {
|
||||||
|
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
|
||||||
type LogAction =
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
| { type: 'ADD_LOG'; payload: DetectionLogEntry }
|
const secs = Math.floor(safeSeconds % 60);
|
||||||
| { type: 'CLEAR' };
|
|
||||||
|
|
||||||
function logsReducer(
|
|
||||||
state: DetectionLogEntry[],
|
|
||||||
action: LogAction,
|
|
||||||
): DetectionLogEntry[] {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'ADD_LOG':
|
|
||||||
// Move to top if already exists, or just add to top
|
|
||||||
const filtered = state.filter(
|
|
||||||
(log) => log.frame !== action.payload.frame,
|
|
||||||
);
|
|
||||||
return [action.payload, ...filtered].slice(0, MAX_LOGS);
|
|
||||||
case 'CLEAR':
|
|
||||||
return [];
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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')}`;
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VideoPlayerSection({
|
export default function VideoPlayerSection({
|
||||||
data,
|
data,
|
||||||
videoId,
|
videoId,
|
||||||
videoFile,
|
|
||||||
detectionType,
|
detectionType,
|
||||||
projectId,
|
projectId,
|
||||||
}: VideoPlayerSectionProps) {
|
}: VideoPlayerSectionProps) {
|
||||||
// Refs
|
|
||||||
const playerRef = useRef<VideoCanvasPlayerRef>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const sortedLogs = useMemo(
|
||||||
const loggedFrames = useRef<Set<number>>(new Set());
|
() =>
|
||||||
|
[...(data.logs || [])].sort(
|
||||||
// State
|
(a, b) => a.timestamp_seconds - b.timestamp_seconds,
|
||||||
const [currentFrame, setCurrentFrame] = useState(0);
|
|
||||||
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 detectionMode = data.detection_mode || detectionType;
|
|
||||||
const modeConfig = getDetectionModeConfig(detectionMode);
|
|
||||||
const enabledTypes = getEnabledDetectionTypes(detectionMode);
|
|
||||||
|
|
||||||
const initialCounts = useMemo(() => {
|
|
||||||
const counts: any = {};
|
|
||||||
enabledTypes.forEach((type) => {
|
|
||||||
counts[type.frameCountKey] = 0;
|
|
||||||
});
|
|
||||||
return counts as DetectionCounts;
|
|
||||||
}, [enabledTypes]);
|
|
||||||
|
|
||||||
const [currentFrameCounts, setCurrentFrameCounts] =
|
|
||||||
useState<DetectionCounts>(initialCounts);
|
|
||||||
|
|
||||||
// Logs Reducer
|
|
||||||
const [logs, dispatchLogs] = useReducer(logsReducer, []);
|
|
||||||
|
|
||||||
// Memoize a unified data object with frames generated if missing
|
|
||||||
const normalizedData = useMemo(() => {
|
|
||||||
if (data.frames && Array.isArray(data.frames) && data.frames.length > 0) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synthesize frames from lists if not present (specifically for gemini_video)
|
|
||||||
// Other models like YOLO return data.frames directly
|
|
||||||
const framesMap = new Map<number, any>();
|
|
||||||
|
|
||||||
// Sort all detections by their first_detected_frame
|
|
||||||
const allDetections: any[] = [];
|
|
||||||
enabledTypes.forEach((type) => {
|
|
||||||
const list = (data as any)[type.listKey];
|
|
||||||
if (list && Array.isArray(list)) {
|
|
||||||
list.forEach((item: any) => {
|
|
||||||
allDetections.push({ ...item, _detType: type.id });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
allDetections.sort(
|
|
||||||
(a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Current counts for sticky stats
|
|
||||||
const currentCounts: Record<string, number> = {};
|
|
||||||
enabledTypes.forEach((t) => {
|
|
||||||
currentCounts[t.frameCountKey] = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
allDetections.forEach((det) => {
|
|
||||||
const frameId = det.first_detected_frame || det.frame_number || 0;
|
|
||||||
|
|
||||||
// Spread detection across multiple frames so it stays visible (persistence)
|
|
||||||
// Gemini detections are sparse, so showing them for ~1 second (30 frames) helps
|
|
||||||
const persistenceFrames = 30;
|
|
||||||
|
|
||||||
for (let i = 0; i < persistenceFrames; i++) {
|
|
||||||
const targetFrame = frameId + i;
|
|
||||||
|
|
||||||
if (!framesMap.has(targetFrame)) {
|
|
||||||
framesMap.set(targetFrame, { frame_id: targetFrame, detections: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameData = framesMap.get(targetFrame);
|
|
||||||
|
|
||||||
// Update cumulative counts only on the first detected frame
|
|
||||||
if (i === 0) {
|
|
||||||
const typeConfig = enabledTypes.find((t) => t.id === det._detType);
|
|
||||||
if (typeConfig) {
|
|
||||||
currentCounts[typeConfig.frameCountKey] =
|
|
||||||
(currentCounts[typeConfig.frameCountKey] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const countCopy = { ...currentCounts };
|
|
||||||
|
|
||||||
frameData.detections.push({
|
|
||||||
...det,
|
|
||||||
type: det.type || det._detType,
|
|
||||||
detection_id: det.detection_id || det.id,
|
|
||||||
count: countCopy,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
frames: Array.from(framesMap.values()).sort(
|
|
||||||
(a, b) => (a.frame_id || 0) - (b.frame_id || 0),
|
|
||||||
),
|
),
|
||||||
};
|
[data.logs],
|
||||||
}, [data, enabledTypes]);
|
|
||||||
|
|
||||||
// Custom Hooks
|
|
||||||
const gpsMap = useGpsMap(normalizedData);
|
|
||||||
const { getNearestDetections, sortedDetectionIndices } = useFrameDetectionMap(
|
|
||||||
normalizedData,
|
|
||||||
detectionType,
|
|
||||||
);
|
);
|
||||||
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(
|
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
||||||
normalizedData.frames || [],
|
sortedLogs[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoized video URL
|
const {
|
||||||
const videoUrl = useMemo(() => {
|
videoUrl,
|
||||||
if (videoFile) return URL.createObjectURL(videoFile);
|
isLoading: isVideoLoading,
|
||||||
if (videoId) return `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}`;
|
isError: isVideoError,
|
||||||
return '';
|
refetch: refetchVideo,
|
||||||
}, [videoFile, videoId]);
|
} = useAnnotatedVideoQuery(data.annotated_video_url);
|
||||||
|
|
||||||
// Clean up Object URL
|
const handleSeek = (log: DetectionResultLog) => {
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (videoFile && videoUrl) URL.revokeObjectURL(videoUrl);
|
|
||||||
};
|
|
||||||
}, [videoFile, videoUrl]);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
if (dets && dets.length > 0) {
|
|
||||||
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,
|
|
||||||
sortedDetectionIndices,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const fps = data.video_info.fps || 30;
|
video.currentTime = log.timestamp_seconds;
|
||||||
const currentFrame = Math.round(video.currentTime * fps);
|
setActiveLog(log);
|
||||||
|
void video.play();
|
||||||
|
};
|
||||||
|
|
||||||
// Snap to the next available frame index that has detections
|
const handleTimeUpdate = () => {
|
||||||
const nextDetectionFrame = sortedDetectionIndices.find(
|
|
||||||
(f) => f >= currentFrame,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextDetectionFrame !== undefined &&
|
|
||||||
nextDetectionFrame !== currentFrame
|
|
||||||
) {
|
|
||||||
video.currentTime = nextDetectionFrame / fps;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFrameUpdate(currentFrame);
|
|
||||||
}, [data.video_info.fps, handleFrameUpdate, sortedDetectionIndices]);
|
|
||||||
|
|
||||||
const seekToFrame = useCallback(
|
|
||||||
(frame: number) => {
|
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video || sortedLogs.length === 0) return;
|
||||||
video.currentTime = frame / data.video_info.fps;
|
|
||||||
},
|
const currentLog = sortedLogs.findLast(
|
||||||
[data.video_info.fps],
|
(log) => log.timestamp_seconds <= video.currentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (currentLog && currentLog.id !== activeLog?.id) {
|
||||||
|
setActiveLog(currentLog);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentCounts = activeLog?.cumulative_counts || data.summary;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Total Detections',
|
||||||
|
value: data.summary.total_detections || 0,
|
||||||
|
icon: Activity,
|
||||||
|
color: 'text-green-500',
|
||||||
|
bgColor: 'bg-green-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Potholes',
|
||||||
|
value: data.summary.unique_potholes || 0,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: 'text-orange-500',
|
||||||
|
bgColor: 'bg-orange-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Signboards',
|
||||||
|
value: data.summary.unique_signboards || 0,
|
||||||
|
icon: SignpostBig,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bgColor: 'bg-blue-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'FPS',
|
||||||
|
value: data.fps.toFixed(1),
|
||||||
|
icon: Gauge,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
bgColor: 'bg-purple-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Duration',
|
||||||
|
value: formatDuration(data.duration_seconds),
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-cyan-500',
|
||||||
|
bgColor: 'bg-cyan-500/10',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
@@ -336,7 +137,7 @@ export default function VideoPlayerSection({
|
|||||||
Detection Playback
|
Detection Playback
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Real-time object detection analysis
|
Annotated video with backend detection logs
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,52 +145,137 @@ export default function VideoPlayerSection({
|
|||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<VideoCanvasPlayer
|
<div className="relative overflow-hidden rounded-lg border bg-black">
|
||||||
ref={playerRef}
|
{videoUrl ? (
|
||||||
videoRef={videoRef}
|
<video
|
||||||
canvasRef={canvasRef}
|
ref={videoRef}
|
||||||
videoUrl={videoUrl}
|
src={videoUrl}
|
||||||
videoWidth={data.video_info.width}
|
controls
|
||||||
videoHeight={data.video_info.height}
|
preload="metadata"
|
||||||
videoError={videoError}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedData={handleLoadedData}
|
className="block w-full aspect-video"
|
||||||
onEnded={handleVideoEnded}
|
|
||||||
onSeeked={handleVideoSeeked}
|
|
||||||
currentDetections={
|
|
||||||
getNearestDetections(currentFrame, sortedDetectionIndices) ||
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetectionStatsBar
|
|
||||||
currentFrameCounts={currentFrameCounts}
|
|
||||||
currentFrame={currentFrame}
|
|
||||||
lastDetectedLat={lastDetectedLat}
|
|
||||||
lastDetectedLng={lastDetectedLng}
|
|
||||||
detectionMode={detectionMode}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video w-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
||||||
|
{isVideoError ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="h-7 w-7 text-destructive" />
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Failed to load the annotated video.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refetchVideo()}
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
||||||
|
<p className="text-xs font-medium uppercase tracking-widest">
|
||||||
|
{isVideoLoading
|
||||||
|
? 'Loading annotated video...'
|
||||||
|
: 'Preparing video...'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetectionLogs logs={logs} onSeek={seekToFrame} />
|
<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={`p-2 rounded-md mb-2 ${stat.bgColor}`}>
|
||||||
|
<Icon className={`h-5 w-5 ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`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>
|
||||||
|
|
||||||
|
<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 uppercase tracking-tight text-orange-500">
|
||||||
|
Potholes:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-orange-500">
|
||||||
|
{currentCounts.unique_potholes || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-tight text-blue-500">
|
||||||
|
Signboards:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-blue-500">
|
||||||
|
{currentCounts.unique_signboards || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-tight text-green-500">
|
||||||
|
Total:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-green-500">
|
||||||
|
{currentCounts.total_detections || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeLog && (
|
||||||
|
<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">
|
||||||
|
{activeLog.frame}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{typeof activeLog.latitude === 'number' &&
|
||||||
|
typeof activeLog.longitude === 'number' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">
|
||||||
|
{activeLog.latitude.toFixed(7)},{' '}
|
||||||
|
{activeLog.longitude.toFixed(7)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetectionLogs
|
||||||
|
logs={sortedLogs}
|
||||||
|
activeLogId={activeLog?.id}
|
||||||
|
onSeek={handleSeek}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
<DetailedSummarySection
|
||||||
projectId={projectId || ''}
|
projectId={projectId || ''}
|
||||||
videoId={videoId}
|
videoId={videoId}
|
||||||
show={showSummary}
|
show={Boolean(projectId)}
|
||||||
detectionType={detectionType}
|
detectionType={detectionType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Activity, Film } from 'lucide-react';
|
import { Activity, Film, MapPin } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { DetectionLogEntry } from '@/types';
|
import { DetectionResultLog } from '@/types';
|
||||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DetectionLogsProps {
|
interface DetectionLogsProps {
|
||||||
logs: DetectionLogEntry[];
|
logs: DetectionResultLog[];
|
||||||
onSeek: (frame: number) => void;
|
activeLogId?: string;
|
||||||
|
onSeek: (log: DetectionResultLog) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
|
const formatVideoTime = (seconds: number) => {
|
||||||
|
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
|
||||||
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
|
const secs = Math.floor(safeSeconds % 60);
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -20,7 +29,7 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
|
|||||||
Detection Logs
|
Detection Logs
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="secondary" className="text-[10px] font-bold uppercase">
|
<Badge variant="secondary" className="text-[10px] font-bold uppercase">
|
||||||
Live Logs
|
Backend Logs
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-[430px] rounded-lg border bg-muted/20 p-4">
|
<ScrollArea className="h-[430px] rounded-lg border bg-muted/20 p-4">
|
||||||
@@ -28,55 +37,62 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
|
|||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
<Film className="h-8 w-8 mb-2 opacity-20" />
|
<Film className="h-8 w-8 mb-2 opacity-20" />
|
||||||
<p className="text-xs">Playback to see logs</p>
|
<p className="text-xs">No detections found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, i) => (
|
logs.map((log) => {
|
||||||
<div
|
const typeId = (log.type || '').toLowerCase();
|
||||||
key={`${log.frame}-${i}`}
|
|
||||||
onClick={() => onSeek(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) => {
|
|
||||||
const typeId = (d.type || '').toLowerCase();
|
|
||||||
const typeConfig = DETECTION_TYPES[typeId];
|
const typeConfig = DETECTION_TYPES[typeId];
|
||||||
const label = typeConfig
|
const label =
|
||||||
? typeConfig.label
|
log.label || typeConfig?.label || typeId.replace(/_/g, ' ');
|
||||||
: typeId.replace(/_/g, ' ');
|
const isActive = activeLogId === log.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${d.id}-${j}`} className="space-y-1">
|
<button
|
||||||
|
key={`${log.id}-${log.frame}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSeek(log)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer',
|
||||||
|
isActive && 'border-primary ring-1 ring-primary/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
|
<div>
|
||||||
<div className="text-[13px] font-bold">
|
<div className="text-[13px] font-bold">
|
||||||
{label} ID: {d.id} | Confidence:{' '}
|
{label} ID: {log.id}
|
||||||
{(d.confidence * 100).toFixed(1)}%
|
|
||||||
</div>
|
</div>
|
||||||
{d.bbox && (
|
<div className="text-[11px] text-muted-foreground font-medium">
|
||||||
<div className="text-[12px] text-muted-foreground/80 font-medium">
|
Frame {log.frame}
|
||||||
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-xs font-bold">
|
||||||
|
{formatVideoTime(log.timestamp_seconds)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{log.timestamp_seconds.toFixed(2)}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-[12px] text-muted-foreground/90 font-medium">
|
||||||
|
{typeof log.confidence === 'number' && (
|
||||||
|
<div>Confidence: {(log.confidence * 100).toFixed(1)}%</div>
|
||||||
)}
|
)}
|
||||||
{d.latitude && (
|
{typeof log.latitude === 'number' &&
|
||||||
<div className="text-[12px] text-muted-foreground/80 font-medium">
|
typeof log.longitude === 'number' && (
|
||||||
GPS: {d.latitude.toFixed(8)},{' '}
|
<div className="flex items-center gap-1.5">
|
||||||
{d.longitude?.toFixed(8)}
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{log.latitude.toFixed(8)}, {log.longitude.toFixed(8)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { DetectionCounts } from '@/types';
|
|
||||||
import { getEnabledDetectionTypes } from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
interface DetectionStatsBarProps {
|
|
||||||
currentFrameCounts: DetectionCounts;
|
|
||||||
currentFrame: number;
|
|
||||||
lastDetectedLat: number | null;
|
|
||||||
lastDetectedLng: number | null;
|
|
||||||
detectionMode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DetectionStatsBar = memo(
|
|
||||||
({
|
|
||||||
currentFrameCounts,
|
|
||||||
currentFrame,
|
|
||||||
lastDetectedLat,
|
|
||||||
lastDetectedLng,
|
|
||||||
detectionMode,
|
|
||||||
}: DetectionStatsBarProps) => {
|
|
||||||
const enabledTypes = getEnabledDetectionTypes(detectionMode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
{enabledTypes.map((type) => (
|
|
||||||
<div
|
|
||||||
key={type.id}
|
|
||||||
className="flex items-center gap-1.5 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-[11px] font-bold uppercase tracking-tight"
|
|
||||||
style={{ color: type.color }}
|
|
||||||
>
|
|
||||||
{type.label}:
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold" style={{ color: type.color }}>
|
|
||||||
{(currentFrameCounts as any)[type.frameCountKey] || 0}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
DetectionStatsBar.displayName = 'DetectionStatsBar';
|
|
||||||
|
|
||||||
export default DetectionStatsBar;
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'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 } from '@/types';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
getDetectionModeConfig,
|
|
||||||
getEnabledDetectionTypes,
|
|
||||||
} from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
interface SummarySectionProps {
|
|
||||||
data: DetectionData;
|
|
||||||
show: boolean;
|
|
||||||
detectionType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SummarySection = ({ data, show, detectionType }: SummarySectionProps) => {
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
const detectionMode = data.detection_mode || detectionType;
|
|
||||||
const modeConfig = getDetectionModeConfig(detectionMode);
|
|
||||||
const enabledTypes = getEnabledDetectionTypes(detectionMode);
|
|
||||||
|
|
||||||
const detectionStats = enabledTypes.map((type) => ({
|
|
||||||
label: type.label,
|
|
||||||
value: (data.summary as any)[type.countKey] || 0,
|
|
||||||
icon: type.id.includes('sign')
|
|
||||||
? SignpostBig
|
|
||||||
: type.id.includes('culvert')
|
|
||||||
? Target
|
|
||||||
: AlertTriangle,
|
|
||||||
color: `text-[${type.color}]`,
|
|
||||||
customColor: type.color,
|
|
||||||
bgColor: 'bg-muted/30',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const globalStats = [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = [...detectionStats, ...globalStats];
|
|
||||||
|
|
||||||
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)}
|
|
||||||
style={
|
|
||||||
(stat as any).customColor
|
|
||||||
? { color: (stat as any).customColor }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn('text-xl font-bold', stat.color)}
|
|
||||||
style={
|
|
||||||
(stat as any).customColor
|
|
||||||
? { color: (stat as any).customColor }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{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 SummarySection;
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'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<HTMLVideoElement | null>;
|
|
||||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
|
||||||
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<
|
|
||||||
VideoCanvasPlayerRef,
|
|
||||||
VideoCanvasPlayerProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
videoRef,
|
|
||||||
canvasRef,
|
|
||||||
videoUrl,
|
|
||||||
videoWidth,
|
|
||||||
videoHeight,
|
|
||||||
videoError,
|
|
||||||
onLoadedData,
|
|
||||||
onEnded,
|
|
||||||
onSeeked,
|
|
||||||
currentDetections,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(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 (
|
|
||||||
<div className="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: `${videoWidth}/${videoHeight}` }}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={videoUrl}
|
|
||||||
controls
|
|
||||||
className="w-full h-full"
|
|
||||||
onLoadedData={onLoadedData}
|
|
||||||
onEnded={onEnded}
|
|
||||||
onSeeked={onSeeked}
|
|
||||||
/>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="absolute top-0 left-0 pointer-events-none w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoCanvasPlayer.displayName = 'VideoCanvasPlayer';
|
|
||||||
|
|
||||||
export default VideoCanvasPlayer;
|
|
||||||
@@ -54,6 +54,6 @@ export const API_ROUTES = {
|
|||||||
LIST: '/videos',
|
LIST: '/videos',
|
||||||
UPLOAD: '/biz/api/v1/upload',
|
UPLOAD: '/biz/api/v1/upload',
|
||||||
STATUS: (id: string) => `/status/${id}`,
|
STATUS: (id: string) => `/status/${id}`,
|
||||||
RESULTS: (id: string) => `/results/${id}`,
|
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ export interface DetectionTypeConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
countKey: string; // The key in summary object from API (e.g., unique_pothole)
|
|
||||||
listKey: string; // The key for the list of detections in DetectionData (e.g., pothole_list)
|
|
||||||
frameCountKey: string; // The key in per-frame count object (e.g., pothole)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectionModeConfig {
|
export interface DetectionModeConfig {
|
||||||
@@ -22,65 +19,41 @@ export const DETECTION_TYPES: Record<string, DetectionTypeConfig> = {
|
|||||||
id: 'pothole',
|
id: 'pothole',
|
||||||
label: 'Pothole',
|
label: 'Pothole',
|
||||||
color: 'var(--chart-1)',
|
color: 'var(--chart-1)',
|
||||||
countKey: 'unique_pothole',
|
|
||||||
listKey: 'pothole_list',
|
|
||||||
frameCountKey: 'pothole',
|
|
||||||
},
|
},
|
||||||
defected_sign_board: {
|
defected_sign_board: {
|
||||||
id: 'defected_sign_board',
|
id: 'defected_sign_board',
|
||||||
label: 'Defect Sign Board',
|
label: 'Defect Sign Board',
|
||||||
color: 'var(--chart-2)',
|
color: 'var(--chart-2)',
|
||||||
countKey: 'unique_defected_sign_board',
|
|
||||||
listKey: 'defected_sign_board_list',
|
|
||||||
frameCountKey: 'defected_sign_board',
|
|
||||||
},
|
},
|
||||||
road_crack: {
|
road_crack: {
|
||||||
id: 'road_crack',
|
id: 'road_crack',
|
||||||
label: 'Road Crack',
|
label: 'Road Crack',
|
||||||
color: 'var(--chart-3)',
|
color: 'var(--chart-3)',
|
||||||
countKey: 'unique_road_crack',
|
|
||||||
listKey: 'road_crack_list',
|
|
||||||
frameCountKey: 'road_crack',
|
|
||||||
},
|
},
|
||||||
damaged_road_marking: {
|
damaged_road_marking: {
|
||||||
id: 'damaged_road_marking',
|
id: 'damaged_road_marking',
|
||||||
label: 'Damage Road Mark',
|
label: 'Damage Road Mark',
|
||||||
color: 'var(--chart-4)',
|
color: 'var(--chart-4)',
|
||||||
countKey: 'unique_damaged_road_marking',
|
|
||||||
listKey: 'damaged_road_marking_list',
|
|
||||||
frameCountKey: 'damaged_road_marking',
|
|
||||||
},
|
},
|
||||||
good_sign_board: {
|
good_sign_board: {
|
||||||
id: 'good_sign_board',
|
id: 'good_sign_board',
|
||||||
label: 'Good Sign Board',
|
label: 'Good Sign Board',
|
||||||
color: 'var(--chart-5)',
|
color: 'var(--chart-5)',
|
||||||
countKey: 'unique_good_sign_board',
|
|
||||||
listKey: 'good_sign_board_list',
|
|
||||||
frameCountKey: 'good_sign_board',
|
|
||||||
},
|
},
|
||||||
drain_issue: {
|
drain_issue: {
|
||||||
id: 'drain_issue',
|
id: 'drain_issue',
|
||||||
label: 'Drain Issue',
|
label: 'Drain Issue',
|
||||||
color: 'var(--chart-6)',
|
color: 'var(--chart-6)',
|
||||||
countKey: 'unique_drain_issue',
|
|
||||||
listKey: 'drain_issue_list',
|
|
||||||
frameCountKey: 'drain_issue',
|
|
||||||
},
|
},
|
||||||
good_culvert: {
|
good_culvert: {
|
||||||
id: 'good_culvert',
|
id: 'good_culvert',
|
||||||
label: 'Good Culvert',
|
label: 'Good Culvert',
|
||||||
color: 'var(--chart-5)',
|
color: 'var(--chart-5)',
|
||||||
countKey: 'unique_good_culvert',
|
|
||||||
listKey: 'good_culvert_list',
|
|
||||||
frameCountKey: 'good_culvert',
|
|
||||||
},
|
},
|
||||||
defective_culvert: {
|
defective_culvert: {
|
||||||
id: 'defective_culvert',
|
id: 'defective_culvert',
|
||||||
label: 'Defective Culvert',
|
label: 'Defective Culvert',
|
||||||
color: 'var(--chart-8)',
|
color: 'var(--chart-8)',
|
||||||
countKey: 'unique_defective_culvert',
|
|
||||||
listKey: 'defective_culvert_list',
|
|
||||||
frameCountKey: 'defective_culvert',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
|
||||||
import { DetectionCounts } from '@/types';
|
|
||||||
|
|
||||||
export const useCumulativeCounts = (frames: any[]) => {
|
|
||||||
const result = useMemo(() => {
|
|
||||||
const map = new Map<number, DetectionCounts>();
|
|
||||||
let lastCounts = {} as DetectionCounts;
|
|
||||||
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 || {};
|
|
||||||
|
|
||||||
// Dynamically compute cumulative max for all keys present in counts
|
|
||||||
const nextCounts = { ...lastCounts };
|
|
||||||
Object.keys(frameCounts).forEach((key) => {
|
|
||||||
const currentVal = (nextCounts as any)[key] || 0;
|
|
||||||
const newVal = (frameCounts as any)[key] || 0;
|
|
||||||
(nextCounts as any)[key] = Math.max(currentVal, newVal);
|
|
||||||
});
|
|
||||||
lastCounts = nextCounts;
|
|
||||||
}
|
|
||||||
map.set(frameId, { ...lastCounts });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { map, indices };
|
|
||||||
}, [frames]);
|
|
||||||
|
|
||||||
const getStickyCounts = useCallback(
|
|
||||||
(frameNumber: number) => {
|
|
||||||
const { map, indices } = result;
|
|
||||||
if (indices.length === 0) return {} as DetectionCounts;
|
|
||||||
|
|
||||||
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) || ({} as DetectionCounts)
|
|
||||||
: ({} as DetectionCounts);
|
|
||||||
},
|
|
||||||
[result],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { getStickyCounts, sortedFrameIndices: result.indices };
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { DetectionData } from '@/types';
|
|
||||||
import {
|
|
||||||
getEnabledDetectionTypes,
|
|
||||||
DETECTION_TYPES,
|
|
||||||
} from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
export const useFrameDetectionMap = (
|
|
||||||
data: DetectionData,
|
|
||||||
detectionType: string,
|
|
||||||
) => {
|
|
||||||
const frameDetectionMap = useMemo(() => {
|
|
||||||
const map = new Map<number, any[]>();
|
|
||||||
const detectionMode = data.detection_mode || detectionType;
|
|
||||||
const enabledTypes = getEnabledDetectionTypes(detectionMode);
|
|
||||||
const enabledKeys = new Set(enabledTypes.map((t) => t.id));
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
const filteredDetections = flatDetections
|
|
||||||
.filter((d: any) => {
|
|
||||||
const type = (d.type || '').toLowerCase();
|
|
||||||
return enabledKeys.has(type);
|
|
||||||
})
|
|
||||||
.map((d: any) => ({
|
|
||||||
...d,
|
|
||||||
_detType: (d.type || '').toLowerCase(),
|
|
||||||
// Map old ID fields and new ID fields dynamically for backward compatibility
|
|
||||||
[`${(d.type || '').toLowerCase()}_id`]: d.detection_id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (filteredDetections.length > 0) {
|
|
||||||
map.set(frameId, filteredDetections);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Legacy format: separate arrays (potholes, signboards, etc.)
|
|
||||||
let detections: any[] = [];
|
|
||||||
|
|
||||||
enabledTypes.forEach((type) => {
|
|
||||||
// Try common plural naming conventions for legacy support
|
|
||||||
const possibleKeys = [
|
|
||||||
`${type.id}s`,
|
|
||||||
type.id.endsWith('y')
|
|
||||||
? `${type.id.slice(0, -1)}ies`
|
|
||||||
: `${type.id}s`,
|
|
||||||
type.listKey.replace('_list', 's'),
|
|
||||||
type.listKey,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const listKey of possibleKeys) {
|
|
||||||
if ((frameData as any)[listKey]) {
|
|
||||||
detections = [
|
|
||||||
...detections,
|
|
||||||
...(frameData as any)[listKey].map((item: any) => ({
|
|
||||||
...item,
|
|
||||||
_detType: type.id,
|
|
||||||
type: type.id,
|
|
||||||
[`${type.id.toLowerCase()}_id`]:
|
|
||||||
(item as any).detection_id ??
|
|
||||||
(item as any)[`${type.id.toLowerCase()}_id`] ??
|
|
||||||
item.id,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (detections.length > 0) map.set(frameId, detections);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [data, detectionType]);
|
|
||||||
|
|
||||||
const sortedDetectionIndices = useMemo(() => {
|
|
||||||
return Array.from(frameDetectionMap.keys()).sort((a, b) => a - b);
|
|
||||||
}, [frameDetectionMap]);
|
|
||||||
|
|
||||||
const getNearestDetections = (
|
|
||||||
frame: number,
|
|
||||||
sortedIndices: number[],
|
|
||||||
maxSkip = 3,
|
|
||||||
) => {
|
|
||||||
const exact = frameDetectionMap.get(frame);
|
|
||||||
if (exact) return exact;
|
|
||||||
|
|
||||||
let low = 0,
|
|
||||||
high = sortedIndices.length - 1,
|
|
||||||
targetIndex = -1;
|
|
||||||
|
|
||||||
while (low <= high) {
|
|
||||||
const mid = Math.floor((low + high) / 2);
|
|
||||||
if (sortedIndices[mid] <= frame) {
|
|
||||||
targetIndex = sortedIndices[mid];
|
|
||||||
low = mid + 1;
|
|
||||||
} else {
|
|
||||||
high = mid - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetIndex !== -1 && frame - targetIndex <= maxSkip
|
|
||||||
? frameDetectionMap.get(targetIndex)
|
|
||||||
: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { frameDetectionMap, getNearestDetections, sortedDetectionIndices };
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { DetectionData } from '@/types';
|
|
||||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
export const useGpsMap = (data: DetectionData) => {
|
|
||||||
const gpsMap = useMemo(() => {
|
|
||||||
const map = new Map<number, { lat: number; lng: number }>();
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dynamically add items from all configured detection lists
|
|
||||||
Object.values(DETECTION_TYPES).forEach((type) => {
|
|
||||||
if (type.listKey) {
|
|
||||||
addItemsToMap((data as any)[type.listKey]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backward compatibility for generic signboard_list
|
|
||||||
if ((data as any).signboard_list) {
|
|
||||||
addItemsToMap((data as any).signboard_list);
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return gpsMap;
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export const useVideoDetectionLoop = (
|
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
|
||||||
fps: number,
|
|
||||||
onFrameUpdate: (frame: number) => void,
|
|
||||||
) => {
|
|
||||||
const lastProcessedFrame = useRef(-1);
|
|
||||||
const animId = useRef<number>(-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 };
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import { Video, PaginationParams } from '@/types';
|
import { CompletedVideoResult, Video, PaginationParams } from '@/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Video Service
|
* Video Service
|
||||||
@@ -77,8 +77,23 @@ export const videoService = {
|
|||||||
/**
|
/**
|
||||||
* Get analysis results for a video
|
* Get analysis results for a video
|
||||||
*/
|
*/
|
||||||
getVideoResults: async (videoId: string): Promise<any> => {
|
getVideoResults: async (videoId: string): Promise<CompletedVideoResult> => {
|
||||||
const response = await axiosClient.get(API_ROUTES.VIDEOS.RESULTS(videoId));
|
const response = await axiosClient.get(API_ROUTES.VIDEOS.RESULTS(videoId));
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the annotated video as a blob through the authenticated axios client.
|
||||||
|
*
|
||||||
|
* The browser's native `<video src>` request cannot carry the Bearer token,
|
||||||
|
* which makes the protected media endpoint respond with 401. Proxying the
|
||||||
|
* download through axios attaches the auth header (and benefits from the
|
||||||
|
* refresh-on-401 interceptor) so the bytes can be played back locally.
|
||||||
|
*/
|
||||||
|
getAnnotatedVideo: async (url: string): Promise<Blob> => {
|
||||||
|
const response = await axiosClient.get<Blob>(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,73 +1,17 @@
|
|||||||
import { DetectionListItem } from './detection';
|
import { DetectionResultLog } from './detection';
|
||||||
|
|
||||||
/**
|
export type CompletedVideoResult = {
|
||||||
* Analysis result types
|
|
||||||
*/
|
|
||||||
export type DetectionData = {
|
|
||||||
video_id: string;
|
video_id: string;
|
||||||
detection_type?: string;
|
status: 'completed' | string;
|
||||||
output_video_path?: string;
|
annotated_video_url: string;
|
||||||
video_info: {
|
|
||||||
fps: number;
|
fps: number;
|
||||||
width: number;
|
duration_seconds: number;
|
||||||
height: number;
|
|
||||||
total_frames: number;
|
|
||||||
};
|
|
||||||
summary: {
|
|
||||||
unique_defected_sign_board?: number;
|
|
||||||
unique_pothole?: number;
|
|
||||||
unique_road_crack?: number;
|
|
||||||
unique_damaged_road_marking?: number;
|
|
||||||
unique_good_sign_board?: number;
|
|
||||||
unique_drain_issue?: number;
|
|
||||||
unique_good_culvert?: number;
|
|
||||||
unique_defective_culvert?: number;
|
|
||||||
total_road_damage?: number;
|
|
||||||
total_detections: number;
|
|
||||||
total_frames: number;
|
|
||||||
detection_rate: number;
|
|
||||||
};
|
|
||||||
pothole_list?: Array<DetectionListItem>;
|
|
||||||
defected_sign_board_list?: Array<DetectionListItem>;
|
|
||||||
road_crack_list?: Array<DetectionListItem>;
|
|
||||||
damaged_road_marking_list?: Array<DetectionListItem>;
|
|
||||||
good_sign_board_list?: Array<DetectionListItem>;
|
|
||||||
drain_issue_list?: Array<DetectionListItem>;
|
|
||||||
good_culvert_list?: Array<DetectionListItem>;
|
|
||||||
defective_culvert_list?: Array<DetectionListItem>;
|
|
||||||
signboard_list?: Array<DetectionListItem>; // Keeping for backward compatibility
|
|
||||||
detection_mode?: string;
|
detection_mode?: string;
|
||||||
frames: Array<{
|
summary: {
|
||||||
frame_id: number;
|
total_detections: number;
|
||||||
// Legacy format: separate arrays
|
unique_potholes?: number;
|
||||||
potholes?: Array<{
|
unique_signboards?: number;
|
||||||
pothole_id: number;
|
[key: string]: number | undefined;
|
||||||
bbox: { x1: number; y1: number; x2: number; y2: number };
|
|
||||||
confidence: number;
|
|
||||||
}>;
|
|
||||||
signboards?: Array<{
|
|
||||||
signboard_id: number;
|
|
||||||
type: string;
|
|
||||||
bbox: { x1: number; y1: number; x2: number; y2: number };
|
|
||||||
confidence: number;
|
|
||||||
}>;
|
|
||||||
// Flat format (pot-sign-detection): unified detections array
|
|
||||||
detections?: Array<{
|
|
||||||
frame_id: number;
|
|
||||||
detection_id: number;
|
|
||||||
type: string;
|
|
||||||
confidence: number;
|
|
||||||
bbox: { x1: number; y1: number; x2: number; y2: number };
|
|
||||||
center?: { x: number; y: number };
|
|
||||||
area?: number;
|
|
||||||
count?: {
|
|
||||||
defected_sign_board: number;
|
|
||||||
pothole: number;
|
|
||||||
road_crack: number;
|
|
||||||
damaged_road_marking: number;
|
|
||||||
good_sign_board: number;
|
|
||||||
drain_issue: number;
|
|
||||||
};
|
};
|
||||||
}>;
|
logs: DetectionResultLog[];
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,39 +25,19 @@ export type DetectionType =
|
|||||||
| 'yoloe_trained_vl'
|
| 'yoloe_trained_vl'
|
||||||
| 'gemini_video';
|
| 'gemini_video';
|
||||||
|
|
||||||
export interface DetectionListItem {
|
export type DetectionResultLog = {
|
||||||
detection_id?: number;
|
id: string;
|
||||||
pothole_id?: number;
|
|
||||||
signboard_id?: number;
|
|
||||||
type: string;
|
type: string;
|
||||||
first_detected_frame: number;
|
label?: string;
|
||||||
first_detected_time: number;
|
|
||||||
confidence: number;
|
|
||||||
bbox?: { x1: number; y1: number; x2: number; y2: number };
|
|
||||||
lat?: number;
|
|
||||||
lng?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectionCounts {
|
|
||||||
defected_sign_board: number;
|
|
||||||
pothole: number;
|
|
||||||
road_crack: number;
|
|
||||||
damaged_road_marking: number;
|
|
||||||
good_sign_board: number;
|
|
||||||
drain_issue: number;
|
|
||||||
good_culvert?: number;
|
|
||||||
defective_culvert?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DetectionLogEntry = {
|
|
||||||
frame: number;
|
frame: number;
|
||||||
detections: Array<{
|
timestamp_seconds: number;
|
||||||
id: number;
|
confidence?: number;
|
||||||
type?: string;
|
latitude?: number | null;
|
||||||
bbox: { x1: number; y1: number; x2: number; y2: number };
|
longitude?: number | null;
|
||||||
confidence: number;
|
cumulative_counts?: {
|
||||||
latitude?: number;
|
unique_potholes?: number;
|
||||||
longitude?: number;
|
unique_signboards?: number;
|
||||||
}>;
|
total_detections?: number;
|
||||||
videoTime: string;
|
[key: string]: number | undefined;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Color mapping for detection types
|
|
||||||
*/
|
|
||||||
export const DETECTION_COLORS: Record<string, string> = Object.keys(
|
|
||||||
DETECTION_TYPES,
|
|
||||||
).reduce(
|
|
||||||
(acc, key) => {
|
|
||||||
acc[key] = DETECTION_TYPES[key].color;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache for resolved colors to avoid DOM access in every frame
|
|
||||||
const resolvedColorCache = new Map<string, string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a color string that might contain CSS variables or modern color formats
|
|
||||||
* into an RGB string that HTML5 Canvas understands.
|
|
||||||
*/
|
|
||||||
function resolveCanvasColor(colorStr: string): string {
|
|
||||||
if (typeof window === 'undefined') return colorStr;
|
|
||||||
|
|
||||||
// Return from cache if already resolved
|
|
||||||
if (resolvedColorCache.has(colorStr)) {
|
|
||||||
return resolvedColorCache.get(colorStr)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a temporary element to let the browser resolve the color (handles var(), oklch(), etc.)
|
|
||||||
try {
|
|
||||||
const temp = document.createElement('div');
|
|
||||||
temp.style.color = colorStr;
|
|
||||||
temp.style.display = 'none';
|
|
||||||
document.body.appendChild(temp);
|
|
||||||
const resolved = getComputedStyle(temp).color;
|
|
||||||
document.body.removeChild(temp);
|
|
||||||
|
|
||||||
if (resolved && resolved !== 'transparent') {
|
|
||||||
resolvedColorCache.set(colorStr, resolved);
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to resolve color:', colorStr, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return colorStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 rawColor = DETECTION_COLORS[type] || '#3b82f6';
|
|
||||||
const boxColor = resolveCanvasColor(rawColor);
|
|
||||||
|
|
||||||
// 1. Draw Border (Always Solid)
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
ctx.strokeStyle = boxColor;
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
|
||||||
|
|
||||||
// 2. Draw Fill (Transparent/Light - using globalAlpha for maximum compatibility)
|
|
||||||
ctx.globalAlpha = 0.1; // 10% Opacity correctly shows the road
|
|
||||||
ctx.fillStyle = boxColor;
|
|
||||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
|
||||||
|
|
||||||
// 3. Draw Label
|
|
||||||
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 (Solid for readability)
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
ctx.fillStyle = boxColor;
|
|
||||||
ctx.fillRect(x1, y1 - 20, metrics.width + 10, 20);
|
|
||||||
|
|
||||||
// Label Text (Solid White)
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillText(label, x1 + 5, y1 - 6);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user