refactor(results): support class-based detection summaries
This commit is contained in:
@@ -1,27 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/videoPlayerSection';
|
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CompletedVideoResult, DetectionType } from '@/types';
|
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
|
||||||
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
||||||
|
|
||||||
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 };
|
||||||
@@ -33,30 +19,19 @@ export default function VideoResultsPage() {
|
|||||||
error: queryError,
|
error: queryError,
|
||||||
} = useVideoResultsQuery(videoId);
|
} = useVideoResultsQuery(videoId);
|
||||||
|
|
||||||
const detectionType = useMemo<DetectionType>(
|
|
||||||
() =>
|
|
||||||
detectionData ? inferDetectionType(detectionData) : 'pothole-detection',
|
|
||||||
[detectionData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const error = isError
|
const error = isError
|
||||||
? queryError instanceof Error
|
? queryError instanceof Error
|
||||||
? queryError.message
|
? queryError.message
|
||||||
: 'Failed to load results'
|
: 'Failed to load results'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
const config = getDetectionModeConfig(detectionType);
|
|
||||||
return `${config.label} Results`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <VideoResultsSkeleton />;
|
return <VideoResultsSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -65,7 +40,7 @@ export default function VideoResultsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={'Detection Results'}
|
title="Detection Results"
|
||||||
description={`Video ID: ${videoId}`}
|
description={`Video ID: ${videoId}`}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -1,48 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Activity, AlertTriangle, MapPin, SignpostBig } from 'lucide-react';
|
import { Activity, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
|
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
|
||||||
|
|
||||||
|
import { getDefectVisual } from './defectVisualConfig';
|
||||||
|
|
||||||
interface CurrentDetectionBarProps {
|
interface CurrentDetectionBarProps {
|
||||||
activeLog?: DetectionResultLog;
|
activeLog?: DetectionResultLog;
|
||||||
summary: CompletedVideoResult['summary'];
|
data: CompletedVideoResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CurrentDetectionBar({
|
export default function CurrentDetectionBar({
|
||||||
activeLog,
|
activeLog,
|
||||||
summary,
|
data,
|
||||||
}: CurrentDetectionBarProps) {
|
}: CurrentDetectionBarProps) {
|
||||||
const currentCounts = activeLog?.cumulative_counts || summary;
|
const defectCounts = activeLog
|
||||||
const countItems = [
|
? activeLog.running_counts.by_class
|
||||||
{
|
: data.defect_counts;
|
||||||
label: 'Potholes',
|
const totalDetections = activeLog
|
||||||
value: currentCounts.unique_potholes || 0,
|
? activeLog.running_counts.total
|
||||||
icon: AlertTriangle,
|
: data.summary.total_detections;
|
||||||
className: 'text-orange-500',
|
const { latitude, longitude } = activeLog?.location ?? {
|
||||||
iconClassName: 'bg-orange-500/10 text-orange-500',
|
latitude: undefined,
|
||||||
},
|
longitude: undefined,
|
||||||
{
|
};
|
||||||
label: 'Signboards',
|
|
||||||
value: currentCounts.unique_signboards || 0,
|
|
||||||
icon: SignpostBig,
|
|
||||||
className: 'text-blue-500',
|
|
||||||
iconClassName: 'bg-blue-500/10 text-blue-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Total',
|
|
||||||
value: currentCounts.total_detections || 0,
|
|
||||||
icon: Activity,
|
|
||||||
className: 'text-green-500',
|
|
||||||
iconClassName: 'bg-green-500/10 text-green-500',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const latitude = activeLog?.latitude;
|
|
||||||
const longitude = activeLog?.longitude;
|
|
||||||
const coordinates =
|
const coordinates =
|
||||||
typeof latitude === 'number' && typeof longitude === 'number'
|
typeof latitude === 'number' && typeof longitude === 'number'
|
||||||
? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}`
|
? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const gridColumns =
|
||||||
|
defectCounts.length >= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-lg border bg-card/80">
|
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||||
@@ -67,33 +55,48 @@ export default function CurrentDetectionBar({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
<div className={`grid grid-cols-1 gap-2 p-3 ${gridColumns}`}>
|
||||||
{countItems.map((item) => {
|
{defectCounts.map((item) => {
|
||||||
const Icon = item.icon;
|
const visual = getDefectVisual(item.class_name);
|
||||||
|
const Icon = visual.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.class_name}
|
||||||
className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60"
|
className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`flex size-8 shrink-0 items-center justify-center rounded-md ${item.iconClassName}`}
|
className={`flex size-8 shrink-0 items-center justify-center rounded-md ${visual.iconClassName}`}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{item.label}
|
{item.display_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className={`text-2xl font-bold leading-none ${item.className}`}
|
className={`text-2xl font-bold leading-none ${visual.colorClassName}`}
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.count}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-green-500/10 text-green-500">
|
||||||
|
<Activity className="size-4" />
|
||||||
|
</span>
|
||||||
|
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold leading-none text-green-500">
|
||||||
|
{totalDetections}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
54
src/components/video/defectVisualConfig.ts
Normal file
54
src/components/video/defectVisualConfig.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
CircleAlert,
|
||||||
|
CircleDot,
|
||||||
|
Droplets,
|
||||||
|
LucideIcon,
|
||||||
|
SignpostBig,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type DefectVisualConfig = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
colorClassName: string;
|
||||||
|
iconClassName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
|
||||||
|
manhole_cover: {
|
||||||
|
icon: CircleDot,
|
||||||
|
colorClassName: 'text-zinc-500',
|
||||||
|
iconClassName: 'bg-zinc-500/10 text-zinc-500',
|
||||||
|
},
|
||||||
|
pothole: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
colorClassName: 'text-orange-500',
|
||||||
|
iconClassName: 'bg-orange-500/10 text-orange-500',
|
||||||
|
},
|
||||||
|
road_crack: {
|
||||||
|
icon: CircleAlert,
|
||||||
|
colorClassName: 'text-rose-500',
|
||||||
|
iconClassName: 'bg-rose-500/10 text-rose-500',
|
||||||
|
},
|
||||||
|
sign_board: {
|
||||||
|
icon: SignpostBig,
|
||||||
|
colorClassName: 'text-blue-500',
|
||||||
|
iconClassName: 'bg-blue-500/10 text-blue-500',
|
||||||
|
},
|
||||||
|
water_puddle: {
|
||||||
|
icon: Droplets,
|
||||||
|
colorClassName: 'text-cyan-500',
|
||||||
|
iconClassName: 'bg-cyan-500/10 text-cyan-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_VISUAL: DefectVisualConfig = {
|
||||||
|
icon: Activity,
|
||||||
|
colorClassName: 'text-green-500',
|
||||||
|
iconClassName: 'bg-green-500/10 text-green-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefectVisual = (className: string) =>
|
||||||
|
DEFECT_VISUALS[className] || DEFAULT_VISUAL;
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Activity, Film, MapPin } from 'lucide-react';
|
import { Activity, Film, MapPin } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { DetectionResultLog } from '@/types';
|
import { DetectionResultLog } from '@/types';
|
||||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface DetectionLogsProps {
|
interface DetectionLogsProps {
|
||||||
@@ -34,7 +32,7 @@ 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">
|
||||||
<h4 className="text-sm font-bold flex items-center gap-2">
|
<h4 className="flex items-center gap-2 text-sm font-bold">
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
Detection Logs
|
Detection Logs
|
||||||
</h4>
|
</h4>
|
||||||
@@ -43,20 +41,18 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
|||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{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="mb-2 h-8 w-8 opacity-20" />
|
||||||
<p className="text-xs">No detections found</p>
|
<p className="text-xs">No detections found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => {
|
logs.map((log) => {
|
||||||
const typeId = (log.type || '').toLowerCase();
|
const timestampSeconds = log.frame.timestamp_seconds;
|
||||||
const typeConfig = DETECTION_TYPES[typeId];
|
const { latitude, longitude } = log.location;
|
||||||
const label =
|
|
||||||
log.label || typeConfig?.label || typeId.replace(/_/g, ' ');
|
|
||||||
const isActive = activeLogId === log.id;
|
const isActive = activeLogId === log.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${log.id}-${log.frame}`}
|
key={`${log.id}-${log.frame.number}`}
|
||||||
ref={isActive ? activeLogRef : null}
|
ref={isActive ? activeLogRef : null}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSeek(log)}
|
onClick={() => onSeek(log)}
|
||||||
@@ -65,35 +61,35 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
|||||||
isActive && 'border-primary bg-primary/5',
|
isActive && 'border-primary bg-primary/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4 mb-3">
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[13px] font-bold">
|
<div className="text-[13px] font-bold">
|
||||||
{label} ID: {log.id}
|
{log.detection.display_name} ID: {log.id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground font-medium">
|
<div className="text-[11px] font-medium text-muted-foreground">
|
||||||
Frame {log.frame}
|
Frame {log.frame.number}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="shrink-0 text-right">
|
||||||
<div className="text-xs font-bold">
|
<div className="text-xs font-bold">
|
||||||
{formatVideoTime(log.timestamp_seconds)}
|
{formatVideoTime(timestampSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
{log.timestamp_seconds.toFixed(2)}s
|
{timestampSeconds.toFixed(2)}s
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-[12px] text-muted-foreground/90 font-medium">
|
<div className="space-y-2 text-[12px] font-medium text-muted-foreground/90">
|
||||||
{typeof log.confidence === 'number' && (
|
<div>
|
||||||
<div>Confidence: {(log.confidence * 100).toFixed(1)}%</div>
|
Confidence: {(log.detection.confidence * 100).toFixed(1)}%
|
||||||
)}
|
</div>
|
||||||
{typeof log.latitude === 'number' &&
|
{typeof latitude === 'number' &&
|
||||||
typeof log.longitude === 'number' && (
|
typeof longitude === 'number' && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{log.latitude.toFixed(8)}, {log.longitude.toFixed(8)}
|
{latitude.toFixed(8)}, {longitude.toFixed(8)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,81 +1,80 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { Activity, Clock, Gauge } from 'lucide-react';
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Clock,
|
|
||||||
Gauge,
|
|
||||||
SignpostBig,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { CompletedVideoResult } from '@/types';
|
import { CompletedVideoResult } from '@/types';
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
import { getDefectVisual } from './defectVisualConfig';
|
||||||
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
|
|
||||||
const minutes = Math.floor(safeSeconds / 60);
|
const formatDuration = (seconds?: number) => {
|
||||||
const secs = Math.floor(safeSeconds % 60);
|
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '-';
|
||||||
|
|
||||||
|
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')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatFps = (fps?: number) =>
|
||||||
|
typeof fps === 'number' && Number.isFinite(fps) ? fps.toFixed(1) : '-';
|
||||||
|
|
||||||
interface ResultStatsGridProps {
|
interface ResultStatsGridProps {
|
||||||
data: CompletedVideoResult;
|
data: CompletedVideoResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
|
export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
|
||||||
|
const defectStats = data.defect_counts.map((item) => {
|
||||||
|
const visual = getDefectVisual(item.class_name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: item.display_name,
|
||||||
|
value: item.count,
|
||||||
|
icon: visual.icon,
|
||||||
|
color: visual.colorClassName,
|
||||||
|
bgColor: visual.iconClassName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'Total Detections',
|
label: 'Total Detections',
|
||||||
value: data.summary.total_detections || 0,
|
value: data.summary.total_detections,
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
color: 'text-green-500',
|
color: 'text-green-500',
|
||||||
bgColor: 'bg-green-500/10',
|
bgColor: 'bg-green-500/10 text-green-500',
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
...defectStats,
|
||||||
{
|
{
|
||||||
label: 'FPS',
|
label: 'FPS',
|
||||||
value: data.fps.toFixed(1),
|
value: formatFps(data.summary.fps),
|
||||||
icon: Gauge,
|
icon: Gauge,
|
||||||
color: 'text-purple-500',
|
color: 'text-purple-500',
|
||||||
bgColor: 'bg-purple-500/10',
|
bgColor: 'bg-purple-500/10 text-purple-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Duration',
|
label: 'Duration',
|
||||||
value: formatDuration(data.duration_seconds),
|
value: formatDuration(data.summary.duration_seconds),
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: 'text-cyan-500',
|
color: 'text-cyan-500',
|
||||||
bgColor: 'bg-cyan-500/10',
|
bgColor: 'bg-cyan-500/10 text-cyan-500',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||||
{stats.map((stat) => {
|
{stats.map((stat) => {
|
||||||
const Icon = stat.icon;
|
const Icon = stat.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center"
|
className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-md mb-2 ${stat.bgColor}`}>
|
<div className={`mb-2 rounded-md p-2 ${stat.bgColor}`}>
|
||||||
<Icon className={`h-5 w-5 ${stat.color}`} />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-xl font-bold ${stat.color}`}>
|
<div className={`text-xl font-bold ${stat.color}`}>
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">
|
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export function useDetectionPlayback({
|
|||||||
}: UseDetectionPlaybackParams) {
|
}: UseDetectionPlaybackParams) {
|
||||||
const shouldPauseAfterSeekRef = useRef(false);
|
const shouldPauseAfterSeekRef = useRef(false);
|
||||||
const sortedLogs = useMemo(
|
const sortedLogs = useMemo(
|
||||||
() => [...(logs || [])].sort((a, b) => a.timestamp_seconds - b.timestamp_seconds),
|
() =>
|
||||||
|
[...(logs || [])].sort(
|
||||||
|
(a, b) => a.frame.timestamp_seconds - b.frame.timestamp_seconds,
|
||||||
|
),
|
||||||
[logs],
|
[logs],
|
||||||
);
|
);
|
||||||
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
||||||
@@ -27,7 +30,7 @@ export function useDetectionPlayback({
|
|||||||
|
|
||||||
shouldPauseAfterSeekRef.current = true;
|
shouldPauseAfterSeekRef.current = true;
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = log.timestamp_seconds;
|
video.currentTime = log.frame.timestamp_seconds;
|
||||||
setActiveLog(log);
|
setActiveLog(log);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ export function useDetectionPlayback({
|
|||||||
if (!video || sortedLogs.length === 0) return;
|
if (!video || sortedLogs.length === 0) return;
|
||||||
|
|
||||||
const currentLog = sortedLogs.findLast(
|
const currentLog = sortedLogs.findLast(
|
||||||
(log) => log.timestamp_seconds <= video.currentTime,
|
(log) => log.frame.timestamp_seconds <= video.currentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentLog && currentLog.id !== activeLog?.id) {
|
if (currentLog && currentLog.id !== activeLog?.id) {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ type VideoPlayerSectionProps = {
|
|||||||
data: CompletedVideoResult;
|
data: CompletedVideoResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VideoPlayerSection({
|
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
|
||||||
data,
|
|
||||||
}: VideoPlayerSectionProps) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const {
|
const {
|
||||||
activeLog,
|
activeLog,
|
||||||
@@ -45,9 +43,9 @@ export default function VideoPlayerSection({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="pb-4 border-b">
|
<CardHeader className="border-b pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded bg-secondary">
|
<div className="rounded bg-secondary p-2">
|
||||||
<Film className="h-5 w-5 text-primary" />
|
<Film className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -61,8 +59,8 @@ export default function VideoPlayerSection({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<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 gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<AnnotatedVideoPlayer
|
<AnnotatedVideoPlayer
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
videoUrl={videoUrl}
|
videoUrl={videoUrl}
|
||||||
@@ -73,10 +71,7 @@ export default function VideoPlayerSection({
|
|||||||
onSeeked={handleSeeked}
|
onSeeked={handleSeeked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CurrentDetectionBar
|
<CurrentDetectionBar activeLog={activeLog} data={data} />
|
||||||
activeLog={activeLog}
|
|
||||||
summary={data.summary}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetectionLogs
|
<DetectionLogs
|
||||||
@@ -91,7 +86,6 @@ export default function VideoPlayerSection({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* Detection mode configurations to support dynamic UI behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DetectionTypeConfig {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectionModeConfig {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
enabledTypes: string[]; // IDs of detection types to show in this mode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DETECTION_TYPES: Record<string, DetectionTypeConfig> = {
|
|
||||||
pothole: {
|
|
||||||
id: 'pothole',
|
|
||||||
label: 'Pothole',
|
|
||||||
color: 'var(--chart-1)',
|
|
||||||
},
|
|
||||||
defected_sign_board: {
|
|
||||||
id: 'defected_sign_board',
|
|
||||||
label: 'Defect Sign Board',
|
|
||||||
color: 'var(--chart-2)',
|
|
||||||
},
|
|
||||||
road_crack: {
|
|
||||||
id: 'road_crack',
|
|
||||||
label: 'Road Crack',
|
|
||||||
color: 'var(--chart-3)',
|
|
||||||
},
|
|
||||||
damaged_road_marking: {
|
|
||||||
id: 'damaged_road_marking',
|
|
||||||
label: 'Damage Road Mark',
|
|
||||||
color: 'var(--chart-4)',
|
|
||||||
},
|
|
||||||
good_sign_board: {
|
|
||||||
id: 'good_sign_board',
|
|
||||||
label: 'Good Sign Board',
|
|
||||||
color: 'var(--chart-5)',
|
|
||||||
},
|
|
||||||
drain_issue: {
|
|
||||||
id: 'drain_issue',
|
|
||||||
label: 'Drain Issue',
|
|
||||||
color: 'var(--chart-6)',
|
|
||||||
},
|
|
||||||
good_culvert: {
|
|
||||||
id: 'good_culvert',
|
|
||||||
label: 'Good Culvert',
|
|
||||||
color: 'var(--chart-5)',
|
|
||||||
},
|
|
||||||
defective_culvert: {
|
|
||||||
id: 'defective_culvert',
|
|
||||||
label: 'Defective Culvert',
|
|
||||||
color: 'var(--chart-8)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DETECTION_MODES: Record<string, DetectionModeConfig> = {
|
|
||||||
yolo: {
|
|
||||||
id: 'yolo',
|
|
||||||
label: 'YOLO Detection',
|
|
||||||
enabledTypes: [
|
|
||||||
'pothole',
|
|
||||||
'defected_sign_board',
|
|
||||||
'road_crack',
|
|
||||||
'damaged_road_marking',
|
|
||||||
'good_sign_board',
|
|
||||||
'drain_issue',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
yolo_vl: {
|
|
||||||
id: 'yolo_vl',
|
|
||||||
label: 'YOLO-VL Detection',
|
|
||||||
enabledTypes: [
|
|
||||||
'pothole',
|
|
||||||
'defected_sign_board',
|
|
||||||
'road_crack',
|
|
||||||
'damaged_road_marking',
|
|
||||||
'good_sign_board',
|
|
||||||
'drain_issue',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'pothole-detection': {
|
|
||||||
id: 'pothole-detection',
|
|
||||||
label: 'Pothole Detection',
|
|
||||||
enabledTypes: [
|
|
||||||
'pothole',
|
|
||||||
'road_crack',
|
|
||||||
'damaged_road_marking',
|
|
||||||
'drain_issue',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'sign-board-detection': {
|
|
||||||
id: 'sign-board-detection',
|
|
||||||
label: 'Signboard Detection',
|
|
||||||
enabledTypes: ['defected_sign_board', 'good_sign_board'],
|
|
||||||
},
|
|
||||||
'pot-sign-detection': {
|
|
||||||
id: 'pot-sign-detection',
|
|
||||||
label: 'Pothole & Signboard Detection',
|
|
||||||
enabledTypes: [
|
|
||||||
'pothole',
|
|
||||||
'defected_sign_board',
|
|
||||||
'road_crack',
|
|
||||||
'damaged_road_marking',
|
|
||||||
'good_sign_board',
|
|
||||||
'drain_issue',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
culvert_detection: {
|
|
||||||
id: 'culvert_detection',
|
|
||||||
label: 'Culvert Detection',
|
|
||||||
enabledTypes: ['good_culvert', 'defective_culvert'],
|
|
||||||
},
|
|
||||||
gemini_video: {
|
|
||||||
id: 'gemini_video',
|
|
||||||
label: 'Gemini AI Analysis',
|
|
||||||
enabledTypes: [
|
|
||||||
'pothole',
|
|
||||||
'defected_sign_board',
|
|
||||||
'road_crack',
|
|
||||||
'damaged_road_marking',
|
|
||||||
'good_sign_board',
|
|
||||||
'drain_issue',
|
|
||||||
'good_culvert',
|
|
||||||
'defective_culvert',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback config for unknown modes
|
|
||||||
export const DEFAULT_DETECTION_MODE: DetectionModeConfig =
|
|
||||||
DETECTION_MODES['pot-sign-detection'];
|
|
||||||
|
|
||||||
export const getDetectionModeConfig = (mode?: string): DetectionModeConfig => {
|
|
||||||
if (!mode) return DEFAULT_DETECTION_MODE;
|
|
||||||
return DETECTION_MODES[mode] || DEFAULT_DETECTION_MODE;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEnabledDetectionTypes = (
|
|
||||||
mode?: string,
|
|
||||||
): DetectionTypeConfig[] => {
|
|
||||||
const config = getDetectionModeConfig(mode);
|
|
||||||
return config.enabledTypes
|
|
||||||
.map((typeId) => DETECTION_TYPES[typeId])
|
|
||||||
.filter(Boolean);
|
|
||||||
};
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { DetectionResultLog } from './detection';
|
import { DetectionClassCount, DetectionResultLog } from './detection';
|
||||||
|
|
||||||
export type CompletedVideoResult = {
|
export type CompletedVideoResult = {
|
||||||
video_id: string;
|
video_id: string;
|
||||||
status: 'completed' | string;
|
status: 'completed' | string;
|
||||||
// annotated_video_url: string;
|
|
||||||
processed_video_url: string | null;
|
processed_video_url: string | null;
|
||||||
raw_video_url: string | null;
|
raw_video_url: string | null;
|
||||||
fps: number;
|
|
||||||
duration_seconds: number;
|
|
||||||
detection_mode?: string;
|
|
||||||
summary: {
|
summary: {
|
||||||
|
fps?: number;
|
||||||
|
duration_seconds?: number;
|
||||||
total_detections: number;
|
total_detections: number;
|
||||||
unique_potholes?: number;
|
|
||||||
unique_signboards?: number;
|
|
||||||
[key: string]: number | undefined;
|
|
||||||
};
|
};
|
||||||
|
defect_counts: DetectionClassCount[];
|
||||||
logs: DetectionResultLog[];
|
logs: DetectionResultLog[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,32 @@
|
|||||||
/**
|
export type DetectionClassCount = {
|
||||||
* Detection types for map and display
|
class_name: string;
|
||||||
*/
|
display_name: string;
|
||||||
export interface Detection {
|
count: number;
|
||||||
id: number;
|
unique_count: number;
|
||||||
video_id: string;
|
};
|
||||||
type: string;
|
|
||||||
class: string;
|
|
||||||
confidence: number;
|
|
||||||
latitude: number | null;
|
|
||||||
longitude: number | null;
|
|
||||||
frame_number: number;
|
|
||||||
timestamp_ms: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DetectionType =
|
|
||||||
| 'pothole-detection'
|
|
||||||
| 'sign-board-detection'
|
|
||||||
| 'pot-sign-detection'
|
|
||||||
| 'culvert_detection'
|
|
||||||
| 'yolo'
|
|
||||||
| 'yolo_vl'
|
|
||||||
| 'sam3'
|
|
||||||
| 'yoloe'
|
|
||||||
| 'yoloe_trained_vl'
|
|
||||||
| 'gemini_video';
|
|
||||||
|
|
||||||
export type DetectionResultLog = {
|
export type DetectionResultLog = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
detection: {
|
||||||
label?: string;
|
id: number;
|
||||||
frame: number;
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
frame: {
|
||||||
|
number: number;
|
||||||
timestamp_seconds: number;
|
timestamp_seconds: number;
|
||||||
confidence?: number;
|
};
|
||||||
latitude?: number | null;
|
location: {
|
||||||
longitude?: number | null;
|
latitude: number | null;
|
||||||
cumulative_counts?: {
|
longitude: number | null;
|
||||||
unique_potholes?: number;
|
};
|
||||||
unique_signboards?: number;
|
running_counts: {
|
||||||
total_detections?: number;
|
total: number;
|
||||||
[key: string]: number | undefined;
|
by_class: Array<{
|
||||||
|
class_name: string;
|
||||||
|
display_name: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user