refactor(results): support class-based detection summaries
This commit is contained in:
@@ -1,48 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, AlertTriangle, MapPin, SignpostBig } from 'lucide-react';
|
||||
import { Activity, MapPin } from 'lucide-react';
|
||||
|
||||
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
|
||||
|
||||
import { getDefectVisual } from './defectVisualConfig';
|
||||
|
||||
interface CurrentDetectionBarProps {
|
||||
activeLog?: DetectionResultLog;
|
||||
summary: CompletedVideoResult['summary'];
|
||||
data: CompletedVideoResult;
|
||||
}
|
||||
|
||||
export default function CurrentDetectionBar({
|
||||
activeLog,
|
||||
summary,
|
||||
data,
|
||||
}: CurrentDetectionBarProps) {
|
||||
const currentCounts = activeLog?.cumulative_counts || summary;
|
||||
const countItems = [
|
||||
{
|
||||
label: 'Potholes',
|
||||
value: currentCounts.unique_potholes || 0,
|
||||
icon: AlertTriangle,
|
||||
className: 'text-orange-500',
|
||||
iconClassName: 'bg-orange-500/10 text-orange-500',
|
||||
},
|
||||
{
|
||||
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 defectCounts = activeLog
|
||||
? activeLog.running_counts.by_class
|
||||
: data.defect_counts;
|
||||
const totalDetections = activeLog
|
||||
? activeLog.running_counts.total
|
||||
: data.summary.total_detections;
|
||||
const { latitude, longitude } = activeLog?.location ?? {
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
};
|
||||
const coordinates =
|
||||
typeof latitude === 'number' && typeof longitude === 'number'
|
||||
? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}`
|
||||
: undefined;
|
||||
const gridColumns =
|
||||
defectCounts.length >= 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||
@@ -67,33 +55,48 @@ export default function CurrentDetectionBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||
{countItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
<div className={`grid grid-cols-1 gap-2 p-3 ${gridColumns}`}>
|
||||
{defectCounts.map((item) => {
|
||||
const visual = getDefectVisual(item.class_name);
|
||||
const Icon = visual.icon;
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<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" />
|
||||
</span>
|
||||
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{item.label}
|
||||
{item.display_name}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={`text-2xl font-bold leading-none ${item.className}`}
|
||||
className={`text-2xl font-bold leading-none ${visual.colorClassName}`}
|
||||
>
|
||||
{item.value}
|
||||
{item.count}
|
||||
</p>
|
||||
</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>
|
||||
</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 { Activity, Film, MapPin } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { DetectionResultLog } from '@/types';
|
||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DetectionLogsProps {
|
||||
@@ -34,7 +32,7 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
Detection Logs
|
||||
</h4>
|
||||
@@ -43,20 +41,18 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
||||
<div className="space-y-3 p-4">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Film className="h-8 w-8 mb-2 opacity-20" />
|
||||
<Film className="mb-2 h-8 w-8 opacity-20" />
|
||||
<p className="text-xs">No detections found</p>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const typeId = (log.type || '').toLowerCase();
|
||||
const typeConfig = DETECTION_TYPES[typeId];
|
||||
const label =
|
||||
log.label || typeConfig?.label || typeId.replace(/_/g, ' ');
|
||||
const timestampSeconds = log.frame.timestamp_seconds;
|
||||
const { latitude, longitude } = log.location;
|
||||
const isActive = activeLogId === log.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${log.id}-${log.frame}`}
|
||||
key={`${log.id}-${log.frame.number}`}
|
||||
ref={isActive ? activeLogRef : null}
|
||||
type="button"
|
||||
onClick={() => onSeek(log)}
|
||||
@@ -65,35 +61,35 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
|
||||
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 className="text-[13px] font-bold">
|
||||
{label} ID: {log.id}
|
||||
{log.detection.display_name} ID: {log.id}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium">
|
||||
Frame {log.frame}
|
||||
<div className="text-[11px] font-medium text-muted-foreground">
|
||||
Frame {log.frame.number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs font-bold">
|
||||
{formatVideoTime(log.timestamp_seconds)}
|
||||
{formatVideoTime(timestampSeconds)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{log.timestamp_seconds.toFixed(2)}s
|
||||
{timestampSeconds.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>
|
||||
)}
|
||||
{typeof log.latitude === 'number' &&
|
||||
typeof log.longitude === 'number' && (
|
||||
<div className="space-y-2 text-[12px] font-medium text-muted-foreground/90">
|
||||
<div>
|
||||
Confidence: {(log.detection.confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
{typeof latitude === 'number' &&
|
||||
typeof longitude === 'number' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>
|
||||
{log.latitude.toFixed(8)}, {log.longitude.toFixed(8)}
|
||||
{latitude.toFixed(8)}, {longitude.toFixed(8)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,81 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Gauge,
|
||||
SignpostBig,
|
||||
} from 'lucide-react';
|
||||
import { Activity, Clock, Gauge } from 'lucide-react';
|
||||
import { CompletedVideoResult } from '@/types';
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const secs = Math.floor(safeSeconds % 60);
|
||||
import { getDefectVisual } from './defectVisualConfig';
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
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')}`;
|
||||
};
|
||||
|
||||
const formatFps = (fps?: number) =>
|
||||
typeof fps === 'number' && Number.isFinite(fps) ? fps.toFixed(1) : '-';
|
||||
|
||||
interface ResultStatsGridProps {
|
||||
data: CompletedVideoResult;
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
label: 'Total Detections',
|
||||
value: data.summary.total_detections || 0,
|
||||
value: data.summary.total_detections,
|
||||
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',
|
||||
bgColor: 'bg-green-500/10 text-green-500',
|
||||
},
|
||||
...defectStats,
|
||||
{
|
||||
label: 'FPS',
|
||||
value: data.fps.toFixed(1),
|
||||
value: formatFps(data.summary.fps),
|
||||
icon: Gauge,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
bgColor: 'bg-purple-500/10 text-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Duration',
|
||||
value: formatDuration(data.duration_seconds),
|
||||
value: formatDuration(data.summary.duration_seconds),
|
||||
icon: Clock,
|
||||
color: 'text-cyan-500',
|
||||
bgColor: 'bg-cyan-500/10',
|
||||
bgColor: 'bg-cyan-500/10 text-cyan-500',
|
||||
},
|
||||
];
|
||||
|
||||
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) => {
|
||||
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"
|
||||
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}`}>
|
||||
<Icon className={`h-5 w-5 ${stat.color}`} />
|
||||
<div className={`mb-2 rounded-md p-2 ${stat.bgColor}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</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">
|
||||
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,10 @@ export function useDetectionPlayback({
|
||||
}: UseDetectionPlaybackParams) {
|
||||
const shouldPauseAfterSeekRef = useRef(false);
|
||||
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],
|
||||
);
|
||||
const [activeLog, setActiveLog] = useState<DetectionResultLog | undefined>(
|
||||
@@ -27,7 +30,7 @@ export function useDetectionPlayback({
|
||||
|
||||
shouldPauseAfterSeekRef.current = true;
|
||||
video.pause();
|
||||
video.currentTime = log.timestamp_seconds;
|
||||
video.currentTime = log.frame.timestamp_seconds;
|
||||
setActiveLog(log);
|
||||
};
|
||||
|
||||
@@ -44,7 +47,7 @@ export function useDetectionPlayback({
|
||||
if (!video || sortedLogs.length === 0) return;
|
||||
|
||||
const currentLog = sortedLogs.findLast(
|
||||
(log) => log.timestamp_seconds <= video.currentTime,
|
||||
(log) => log.frame.timestamp_seconds <= video.currentTime,
|
||||
);
|
||||
|
||||
if (currentLog && currentLog.id !== activeLog?.id) {
|
||||
|
||||
Reference in New Issue
Block a user