diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/.gitignore b/GEMstack/onboard/visualization/sr_viz/threeD/.gitignore index 5ef6a5207..d50699435 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/.gitignore +++ b/GEMstack/onboard/visualization/sr_viz/threeD/.gitignore @@ -1,7 +1,26 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Node.js / Next.js +node_modules/ +.next/ +out/ -# dependencies -/node_modules +/build + +# Python / Flask +__pycache__/ +*.pyc +.venv/ +venv/ + +# Environment files +.env +.env.* + +# OS and editor files +.DS_Store +.idea/ +.vscode/ + +# Yarn / PNP /.pnp .pnp.* .yarn/* @@ -10,32 +29,21 @@ !.yarn/releases !.yarn/versions -# testing +# Testing /coverage -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store +# Misc *.pem -# debug +# Debug logs npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* - -# vercel +# Vercel .vercel -# typescript +# TypeScript *.tsbuildinfo next-env.d.ts diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package-lock.json b/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package-lock.json index 5b16ff0f0..92d191e46 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package-lock.json +++ b/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package-lock.json @@ -8,6 +8,7 @@ "hasInstallScript": true, "dependencies": { "nuxt": "^3.16.1", + "nuxt-app": "file:", "three": "^0.174.0", "urdf-loader": "^0.12.4", "vue": "^3.5.13", @@ -7088,6 +7089,10 @@ } } }, + "node_modules/nuxt-app": { + "resolved": "", + "link": true + }, "node_modules/nypm": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz", diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package.json b/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package.json index 8f0c27838..f0b32f728 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package.json +++ b/GEMstack/onboard/visualization/sr_viz/threeD/legacy/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "nuxt": "^3.16.1", + "nuxt-app": "file:", "three": "^0.174.0", "urdf-loader": "^0.12.4", "vue": "^3.5.13", diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light_1.glb b/GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light.glb similarity index 100% rename from GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light_1.glb rename to GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light.glb diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light_2.glb b/GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light_2.glb deleted file mode 100644 index 47161ae23..000000000 Binary files a/GEMstack/onboard/visualization/sr_viz/threeD/public/models/traffic_light_2.glb and /dev/null differ diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/app/page.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/app/page.tsx index 9cc8d24b5..a680a011b 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/app/page.tsx +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/app/page.tsx @@ -1,18 +1,40 @@ "use client"; +import { useSearchParams } from "next/navigation"; import ControlPanel from "@/components/ControlPanel"; import CanvasWrapper from "@/components/CanvasWrapper"; import Scrubber from "@/components/Scrubber"; import { usePlaybackTime } from "@/hooks/usePlaybackTime"; export default function HomePage() { - const { time, reset, restart, play, togglePlay, speed, setPlaybackSpeed, moveToTime, duration, setDuration } = usePlaybackTime(); - + const { + time, + reset, + restart, + play, + togglePlay, + speed, + setPlaybackSpeed, + moveToTime, + duration, + setDuration, + } = usePlaybackTime(); + const searchParams = useSearchParams(); + const folder = searchParams.get("folder") || undefined; + const file = searchParams.get("file") || undefined; return (
- + - +
); } diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Agent.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Agent.tsx index 2525cc54c..b4fd99a5a 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Agent.tsx +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Agent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useMemo, useEffect } from "react"; +import { useRef, useMemo, useEffect, useState } from "react"; import { useFrame } from "@react-three/fiber"; import { Mesh, Object3D, MeshStandardMaterial } from "three"; import { useGLTF } from "@react-three/drei"; @@ -14,13 +14,17 @@ interface AgentProps { } export default function Agent({ id, timeline, time }: AgentProps) { - const ref = useRef(null); + const [mounted, setMounted] = useState(false); + const ref = useRef(null); const { modelPath, scale, rotation, offset, bodyColor } = currentAgent; - const { scene } = useGLTF(modelPath); const clonedScene = useMemo(() => scene.clone(true), [scene]); + useEffect(() => { + setMounted(true); + }, []); + useEffect(() => { clonedScene.traverse((child) => { if ( @@ -34,19 +38,21 @@ export default function Agent({ id, timeline, time }: AgentProps) { }, [clonedScene, bodyColor]); useFrame(() => { - const frame = timeline.find((f) => f.time >= time); - if (frame && ref.current) { - ref.current.position.set(frame.x, 0, frame.y); - ref.current.rotation.y = -frame.yaw; - } + if (!ref.current || timeline.length === 0) return; + + const frame = timeline.find((f) => f.time >= time) ?? timeline.at(-1); + if (!frame) return; + + ref.current.position.set(frame.x, 0, frame.y); + ref.current.rotation.y = -frame.yaw; }); const hasSpawned = timeline.length > 0 && timeline[0].time <= time; - if (!hasSpawned) return null; + if (!mounted || !hasSpawned) return null; return ( } + ref={ref as React.RefObject} object={clonedScene} scale={scale} rotation={rotation} diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/CanvasWrapper.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/CanvasWrapper.tsx index 91d8d2803..6a6c920dd 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/CanvasWrapper.tsx +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/CanvasWrapper.tsx @@ -6,47 +6,57 @@ import { Environment } from "@react-three/drei"; import { useTimelineStore } from "@/hooks/useTimelineStore"; import Vehicle from "./Vehicle"; import Agent from "./Agent"; +import TrafficLight from "./TrafficLight"; +import OtherVehicle from "./OtherVehicle"; import Ground from "./Ground"; export default function CanvasWrapper({ - time, - setDuration, + time, + setDuration, }: { - time: number; - setDuration: (duration: number) => void; + time: number; + setDuration: (duration: number) => void; }) { - const { vehicle, agents } = useTimelineStore(); - useEffect(() => { - if (vehicle.length > 0) { - setDuration(vehicle[vehicle.length - 1].time - vehicle[0].time); - } - }, [vehicle]); - const startTime = vehicle.length > 0 ? vehicle[0].time : 0; - const syncedTime = startTime + time; - - return ( - - - - - - {Object.entries(agents).map(([id, timeline]) => ( - - ))} - - - ); + const { vehicle, agents, trafficLights, otherVehicles } = useTimelineStore(); + + useEffect(() => { + if (vehicle.length > 0) { + setDuration(vehicle[vehicle.length - 1].time - vehicle[0].time); + } + }, [vehicle]); + + const startTime = vehicle.length > 0 ? vehicle[0].time : 0; + const syncedTime = startTime + time; + + return ( + + + + + + + {Object.entries(agents).map(([id, timeline]) => ( + + ))} + + {Object.entries(trafficLights).map(([id, timeline]) => ( + + ))} + + {Object.entries(otherVehicles).map(([id, timeline]) => ( + + ))} + + + + ); } diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/ControlPanel.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/ControlPanel.tsx index 97122e70c..cd8fc418e 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/ControlPanel.tsx +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/ControlPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { TiUpload } from "react-icons/ti"; import { RxCross2 } from "react-icons/rx"; import { buildTimeline } from "@/utils/buildTimeline"; @@ -8,7 +8,15 @@ import { parseLogFile } from "@/utils/parseLogFile"; import { useTimelineStore } from "@/hooks/useTimelineStore"; import { TimelineData } from "@/types/TimelineData"; -export default function ControlPanel({ reset }: { reset: () => void }) { +export default function ControlPanel({ + reset, + folder, + file, +}: { + reset: () => void; + folder?: string; + file?: string; +}) { const [isOpen, setIsOpen] = useState(false); const [fileName, setFileName] = useState(null); const setTimeline = useTimelineStore((state) => state.setTimeline); @@ -25,10 +33,10 @@ export default function ControlPanel({ reset }: { reset: () => void }) { const entries = await parseLogFile(file); const timeline: TimelineData = buildTimeline(entries); setTimeline(timeline); - reset(); // ⏪ Restart animation time - console.log("✅ timeline loaded:", timeline); + reset(); + console.log("timeline loaded:", timeline); } catch (err) { - console.error("❌ Failed to parse log file:", err); + console.error("Failed to parse log file:", err); } }; const handleContextMenu = (event: React.MouseEvent) => { @@ -40,6 +48,41 @@ export default function ControlPanel({ reset }: { reset: () => void }) { } } + useEffect(() => { + if (!folder || !file) return; + + const fetchLog = async () => { + const url = `http://localhost:5000/raw_logs/${encodeURIComponent( + folder + )}/${encodeURIComponent(file)}`; + + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Failed to fetch ${file} from ${url} (status ${res.status})` + ); + } + + const text = await res.text(); + const fakeFile = new File([text], file, { type: "text/plain" }); + + const entries = await parseLogFile(fakeFile); + const timeline = buildTimeline(entries); + + setTimeline(timeline); + reset(); + setFileName(file); + + console.log("[✔] Timeline loaded from Flask API:", timeline); + } catch (err) { + console.error("[✘] Failed to load remote log file:", err); + } + }; + + fetchLog(); + }, [folder, file]); + return ( <>
void }) { />

- {fileName ? `✅ ${fileName}` : "No file loaded"} + {fileName ? `${fileName}` : "No file loaded"}

diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/OtherVehicle.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/OtherVehicle.tsx new file mode 100644 index 000000000..cf9b3658d --- /dev/null +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/OtherVehicle.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useRef, useMemo, useEffect, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { FrameData } from "@/types/FrameData"; +import { currentOtherVehicle } from "@/config/otherVehicleConfig"; +import URDFLoader from "urdf-loader"; + +interface OtherVehicleProps { + id: string; + timeline: FrameData[]; + time: number; +} + +export default function OtherVehicle({ id, timeline, time }: OtherVehicleProps) { + const ref = useRef(null); + const vehicleGroup = useRef(new THREE.Group()); + const [isLoaded, setIsLoaded] = useState(false); + + const targetPosition = useMemo(() => new THREE.Vector3(), []); + const targetQuaternion = useMemo(() => new THREE.Quaternion(), []); + + const { modelPath, scale, rotation, offset, bodyColor } = currentOtherVehicle; + + useEffect(() => { + const loader = new URDFLoader(); + + loader.load( + modelPath, + (robot) => { + robot.scale.set(scale[0], scale[1], scale[2]); + robot.rotation.set(rotation[0], rotation[1], rotation[2]); + robot.position.set(offset[0], offset[1], offset[2]); + + robot.traverse((child) => { + if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) { + child.material = child.material.clone(); + child.material.color.set(bodyColor); + } + }); + + if (vehicleGroup.current) { + vehicleGroup.current.add(robot); + } + + setIsLoaded(true); + }, + undefined, + (error) => { + console.error("URDF loading failed:", error); + } + ); + }, [modelPath, scale, rotation, offset, bodyColor]); + + useFrame(() => { + if (!ref.current || timeline.length === 0) return; + + const frame = timeline.find((f) => f.time >= time) ?? timeline.at(-1); + if (!frame) return; + + targetPosition.set(frame.x, 0, frame.y); + ref.current.position.lerp(targetPosition, 0.2); + + targetQuaternion.setFromEuler(new THREE.Euler(0, -frame.yaw, 0)); + ref.current.quaternion.slerp(targetQuaternion, 0.2); + }); + + const hasSpawned = timeline.length > 0 && timeline[0].time <= time; + if (!isLoaded || !hasSpawned) return null; + + return ( + + + + ); +} diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/TrafficLight.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/TrafficLight.tsx new file mode 100644 index 000000000..5ccced058 --- /dev/null +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/TrafficLight.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useRef, useMemo, useEffect, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import { Mesh, Object3D, MeshStandardMaterial } from "three"; +import { useGLTF } from "@react-three/drei"; +import { FrameData } from "@/types/FrameData"; +import { currentTrafficLight } from "@/config/trafficLightConfig"; + +interface TrafficLightProps { + id: string; + timeline: FrameData[]; + time: number; +} + +export default function TrafficLight({ id, timeline, time }: TrafficLightProps) { + const [mounted, setMounted] = useState(false); + + const ref = useRef(null); + const { modelPath, scale, rotation, offset, bodyColor } = currentTrafficLight; + const { scene } = useGLTF(modelPath); + const clonedScene = useMemo(() => scene.clone(true), [scene]); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + clonedScene.traverse((child) => { + if ( + child instanceof Mesh && + child.material instanceof MeshStandardMaterial + ) { + child.material = child.material.clone(); + child.material.color.set(bodyColor); + } + }); + }, [clonedScene, bodyColor]); + + useFrame(() => { + const frame = timeline.find((f) => f.time >= time); + if (frame && ref.current) { + ref.current.position.set(frame.x, frame.z, frame.y); + ref.current.rotation.y = -frame.yaw; + } + }); + + const hasSpawned = timeline.length > 0 && timeline[0].time <= time; + if (!mounted || !hasSpawned) return null; + + return ( + } + object={clonedScene} + scale={scale} + rotation={rotation} + position={offset} + /> + ); +} diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Vehicle.tsx b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Vehicle.tsx index 22d55138b..5fabb99a1 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Vehicle.tsx +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/components/Vehicle.tsx @@ -14,7 +14,7 @@ interface VehicleProps { export default function Vehicle({ timeline, time }: VehicleProps) { const ref = useRef(null); - const vehicleGroup = useRef(new THREE.Group()); // ← create empty group for the robot + const vehicleGroup = useRef(new THREE.Group()); const mode = useCameraController(ref, timeline, time); const targetPosition = useMemo(() => new THREE.Vector3(), []); diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/cameraConfig.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/cameraConfig.ts index b235ede20..3e30bb358 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/cameraConfig.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/cameraConfig.ts @@ -10,8 +10,8 @@ type CameraConfigMap = { const cameraConfig: CameraConfigMap = { first: { - position: [0.2, 1.5, -0.3], // on top of vehicle - lookAt: [2, 1.5, 0], // looking forward (+Z) + position: [0, 1.3, 0], // on top of vehicle + lookAt: [10, 2, 0], // looking forward (+Z) damping: 0.1, }, chase: { diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/otherVehicleConfig.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/otherVehicleConfig.ts new file mode 100644 index 000000000..91be73319 --- /dev/null +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/otherVehicleConfig.ts @@ -0,0 +1,14 @@ +const otherVehicleConfig = { + car: { + name: "Car", + modelPath: "/models/model/gem_e4.urdf", + scale: [1, 1, 1], + rotation: [-Math.PI / 2, 0, 0], + offset: [0, 0, 0], + bodyColor: "#808080", + }, +}; + +const currentOtherVehicle = otherVehicleConfig.car; + +export { otherVehicleConfig, currentOtherVehicle }; diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/trafficLightConfig.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/trafficLightConfig.ts new file mode 100644 index 000000000..ef86a63ee --- /dev/null +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/trafficLightConfig.ts @@ -0,0 +1,14 @@ +const trafficLightConfig = { + trafficLight: { + name: "Traffic Light", + modelPath: "/models/traffic_light.glb", + scale: [0.03, 0.03, 0.03], + rotation: [0, 0, 0], + offset: [0, 0, 0], + bodyColor: "#FFD700", + }, +}; + +const currentTrafficLight = trafficLightConfig.trafficLight; + +export { trafficLightConfig, currentTrafficLight }; diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/vehicleConfig.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/vehicleConfig.ts index f51360701..a23435ad0 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/config/vehicleConfig.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/config/vehicleConfig.ts @@ -6,29 +6,7 @@ const vehicles = { rotation: [-Math.PI / 2, 0, 0], offset: [0, 0, 0], bodyColor: "#808080", - }, - roadster: { - name: "Roadster", - modelPath: "/models/roadster.glb", - scale: [1, 1, 1], - rotation: [0, Math.PI / 2, 0], - offset: [0, 0, 0], - bodyColor: "#808080", - }, - suv: { - name: "SUV", - modelPath: "/models/roadster.glb", - scale: [1, 1, 1], - rotation: [0, Math.PI / 2, 0], - bodyColor: "#808080", - }, - truck: { - name: "Truck", - modelPath: "/models/roadster.glb", - scale: [1, 1, 1], - rotation: [0, Math.PI, 0], - bodyColor: "#808080", - }, + } }; const currentVehicle = vehicles.gemE4; diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useTimelineStore.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useTimelineStore.ts index 2dd500e93..e82ea1b9b 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useTimelineStore.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useTimelineStore.ts @@ -4,5 +4,7 @@ import { create } from 'zustand'; export const useTimelineStore = create((set) => ({ vehicle: [], agents: {}, + trafficLights: {}, + otherVehicles: {}, setTimeline: (timeline) => set(timeline), })); diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useVehicleControls.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useVehicleControls.ts index 0dbe955a9..27eccfc5f 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useVehicleControls.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/hooks/useVehicleControls.ts @@ -36,7 +36,7 @@ export function useVehicleControls( state.moving = false; - const forward = new Vector3(0, 0, 1).applyEuler(currentRotation); + const forward = new Vector3(1, 0, 0).applyEuler(currentRotation); if (keys['w']) { moveDir.add(forward); diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/types/TimelineData.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/types/TimelineData.ts index 81de7652c..6599240aa 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/types/TimelineData.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/types/TimelineData.ts @@ -3,4 +3,6 @@ import { FrameData } from './FrameData'; export interface TimelineData { vehicle: FrameData[]; agents: Record; + trafficLights: Record; + otherVehicles: Record; } diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/buildTimeline.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/buildTimeline.ts index a0a138002..93be558bd 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/buildTimeline.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/buildTimeline.ts @@ -5,6 +5,8 @@ import { TimelineData } from '@/types/TimelineData'; export function buildTimeline(entries: LogEntry[]): TimelineData { const vehicle: FrameData[] = []; const agents: Record = {}; + const trafficLights: Record = {}; + const otherVehicles: Record = {}; for (const entry of entries) { const pose = entry.data?.pose; @@ -22,12 +24,20 @@ export function buildTimeline(entries: LogEntry[]): TimelineData { if (entry.key === 'vehicle') { vehicle.push(frame); - } else { + } else if (entry.type === 'AgentState') { const key = entry.key.trim(); if (!agents[key]) agents[key] = []; agents[key].push(frame); + } else if (entry.type === 'TrafficLightState') { + const key = entry.key.trim(); + if (!trafficLights[key]) trafficLights[key] = []; + trafficLights[key].push(frame); + } else if (entry.type === 'OtherVehicleState') { + const key = entry.key.trim(); + if (!otherVehicles[key]) otherVehicles[key] = []; + otherVehicles[key].push(frame); } } - return { vehicle, agents }; + return { vehicle, agents, trafficLights, otherVehicles }; } diff --git a/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/parseLogFile.ts b/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/parseLogFile.ts index 6d879c0aa..af59a372e 100644 --- a/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/parseLogFile.ts +++ b/GEMstack/onboard/visualization/sr_viz/threeD/src/utils/parseLogFile.ts @@ -12,12 +12,7 @@ export async function parseLogFile(file: File): Promise { for (const [key, value] of Object.entries(parsed)) { if (key === 'time' || typeof value !== 'object' || value === null) continue; - if ( - key === 'vehicle' && - 'type' in value && - 'data' in value && - (value as any).data?.pose?.frame === 3 - ) { + if (key === 'vehicle' && 'type' in value && 'data' in value && (value as any).data?.pose?.frame === 3) { entries.push({ key, type: (value as any).type, @@ -27,19 +22,20 @@ export async function parseLogFile(file: File): Promise { continue; } - if (key === 'agents') { - for (const [agentId, agentValue] of Object.entries(value)) { - if ( - typeof agentValue === 'object' && - agentValue !== null && - 'data' in agentValue && - (agentValue as any).data?.pose?.frame === 1 - ) { + if (key === 'agents' || key === 'traffic_lights' || key === 'other_vehicles') { + for (const [itemId, itemValue] of Object.entries(value)) { + if (typeof itemValue === 'object' && itemValue !== null && 'data' in itemValue) { + const frame = (itemValue as any).data?.pose?.frame; + + if (key === 'agents' && frame !== 1) { + continue; + } + entries.push({ - key: agentId, - type: (agentValue as any).type ?? 'AgentState', + key: itemId, + type: (itemValue as any).type ?? guessTypeFromKey(key), time, - data: (agentValue as any).data, + data: (itemValue as any).data, }); } } @@ -52,3 +48,10 @@ export async function parseLogFile(file: File): Promise { return entries; } + +function guessTypeFromKey(key: string): string { + if (key === 'agents') return 'AgentState'; + if (key === 'traffic_lights') return 'TrafficLightState'; + if (key === 'other_vehicles') return 'OtherVehicleState'; + return 'Unknown'; +} diff --git a/log_dashboard/README.md b/log_dashboard/README.md new file mode 100644 index 000000000..86c65fdb1 --- /dev/null +++ b/log_dashboard/README.md @@ -0,0 +1,18 @@ +Log Dashboard + +This dashboard can be used to browse GEMSTACK logs in a web browser. + +- It should display summary information (date, run duration, termination reason, sim vs real, launch command) in a table on the splash screen. + +- Can filter logs based on date + +- Upon choosing a log, it displays more detailed information about the run, allows file exporing directly in the browser (Button to open the folder is also present). + +- Upon visualizing behavior.json, you see the trajectory length, and a plot of the trajectory driven. + +To run the code: +1. pip install -r requirements.txt +2. Run python app.py +3. Go to 127.0.0.1:5000 + + diff --git a/log_dashboard/app.py b/log_dashboard/app.py new file mode 100644 index 000000000..ac1e7a384 --- /dev/null +++ b/log_dashboard/app.py @@ -0,0 +1,579 @@ +from flask import Flask, render_template, request, jsonify, send_file +import os +import yaml +import datetime +import functools +import time +from cachelib import SimpleCache +import json +import numpy as np +import platform +import matplotlib +from flask import Response, stream_with_context +from flask_cors import CORS + +# Use the 'Agg' backend which is thread-safe and doesn't require a GUI +matplotlib.use("Agg") + +import matplotlib.pyplot as plt + +app = Flask(__name__) +CORS(app) +CORS(app, origins=["http://localhost:3000"]) + +# Configure cache +cache = SimpleCache(threshold=500, default_timeout=300) # 5 minutes cache timeout + +LOG_DIR = "../logs" + + +def generate_behavior_plots(log_folder, behavior_file, target_frame=3): + """ + Generate a comprehensive visualization plot for vehicle, agents, and trajectory + + Args: + log_folder (str): Name of the log folder + behavior_file (str): Path to the behavior.json file + target_frame (int, optional): Specific frame to filter data. Defaults to None. + + Returns: + dict: Paths to generated plot files + """ + target_frame = 3 + # Define output plot directory + plot_dir = os.path.join("./plots", log_folder, "viz") + os.makedirs(plot_dir, exist_ok=True) + + # Create cache file to track previous plot generation + cache_file = os.path.join(plot_dir, f"plot_cache_frame_{target_frame}.json") + + # Check if plots have been previously generated + if os.path.exists(cache_file): + with open(cache_file, "r") as f: + return json.load(f) + + # Initialize data collections + vehicle_data = [] + agent_data = {} + trajectory_data = [] + + # Parse behavior file + with open(behavior_file, "r") as f: + for line in f: + try: + entry = json.loads(line.strip()) + + # Vehicle state + if "vehicle" in entry and "data" in entry["vehicle"]: + vehicle_state = entry["vehicle"]["data"]["pose"] + # Check frame filter if specified + if ( + target_frame is None + or vehicle_state.get("frame") == target_frame + ): + vehicle_data.append( + { + "time": entry["time"], + "x": vehicle_state.get("x", 0), + "y": vehicle_state.get("y", 0), + "frame": vehicle_state.get("frame"), + } + ) + + # Agent states + if "agents" in entry: + for agent_name, agent_info in entry["agents"].items(): + agent_state = agent_info["data"]["pose"] + # Check frame filter if specified + if ( + target_frame is None + or agent_state.get("frame") == target_frame + ): + if agent_name not in agent_data: + agent_data[agent_name] = [] + + agent_data[agent_name].append( + { + "time": entry["time"], + "x": agent_state.get("x", 0), + "y": agent_state.get("y", 0), + "frame": agent_state.get("frame"), + } + ) + + # Trajectory + if "trajectory" in entry and "data" in entry["trajectory"]: + traj_points = entry["trajectory"]["data"]["points"] + traj_times = entry["trajectory"]["data"]["times"] + traj_frames = entry["trajectory"]["data"].get( + "frames", [None] * len(traj_points) + ) + + trajectory_data = [ + {"x": point[0], "y": point[1], "time": time, "frame": frame} + for point, time, frame in zip( + traj_points, traj_times, traj_frames + ) + if target_frame is None or frame == target_frame + ] + + except json.JSONDecodeError: + continue + + # Comprehensive Plot + plt.figure(figsize=(12, 8)) + + # Plot vehicle trajectory + if vehicle_data: + vehicle_xs = [v["x"] for v in vehicle_data] + vehicle_ys = [v["y"] for v in vehicle_data] + plt.plot( + vehicle_xs, + vehicle_ys, + label="Vehicle Path", + color="red", + linewidth=3, + marker="o", + markersize=5, + ) + + # Plot agent trajectories + for agent_name, positions in agent_data.items(): + agent_xs = [a["x"] for a in positions] + agent_ys = [a["y"] for a in positions] + plt.plot(agent_xs, agent_ys, label=agent_name, marker="x") + + # Plot planned trajectory + if trajectory_data: + traj_xs = [t["x"] for t in trajectory_data] + traj_ys = [t["y"] for t in trajectory_data] + plt.plot( + traj_xs, + traj_ys, + label="Planned Trajectory", + color="green", + linestyle="--", + linewidth=2, + ) + + plt.title(f"Comprehensive Movement Visualization (Frame {target_frame})") + plt.xlabel("X Position") + plt.ylabel("Y Position") + plt.legend() + plt.grid(True, linestyle="--", alpha=0.7) + + # Save the plot + plot_path = os.path.join( + plot_dir, f"comprehensive_trajectory_frame_{target_frame}.png" + ) + plt.savefig(plot_path, dpi=300, bbox_inches="tight") + plt.close() + + # Cache plot file paths + plot_files = {"comprehensive": plot_path} + with open(cache_file, "w") as f: + json.dump(plot_files, f) + + return plot_files + + +@app.route("/view_log//render") +def render_vis_html(log_folder): + print(f"Rendering visualization HTML for log folder: {log_folder}") + return render_template("render.html", log_folder=log_folder) + + +@app.route("/view_log//get_render") +def render_behavior_visualization(log_folder): + """ + Render behavior visualization for a specific log folder + + Returns: + JSON response with plot file paths + """ + log_folder_path = os.path.join(LOG_DIR, log_folder) + + # Find behavior.json file + behavior_files = [f for f in os.listdir(log_folder_path) if f == "behavior.json"] + + if not behavior_files: + return jsonify({"error": "No behavior.json file found"}), 404 + + behavior_file_path = os.path.join(log_folder_path, behavior_files[0]) + + # Get frame from query parameter, default to None if not specified + target_frame = request.args.get("frame", type=int) + + try: + # Generate behavior plots + plot_files = generate_behavior_plots( + log_folder, behavior_file_path, target_frame + ) + print(plot_files) + # return jsonify(plot_files) + # return render_template("render.html", image_path=plot_files) + return send_file(plot_files["comprehensive"], mimetype="image/png") + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# Add a route to serve plot files +@app.route("/plots//viz/") +def serve_plot(log_folder, filename): + """ + Serve plot files from the plots directory + """ + plot_dir = os.path.join("./plots", log_folder, "viz") + return send_file(os.path.join(plot_dir, filename)) + + +def get_cache_key(prefix, *args): + """Generate a cache key with a prefix and arguments""" + return f"{prefix}_{hash(str(args))}" + + +def parse_behavior_data(file_path): + """ + Parse behavior.json file and extract vehicle and agent positions + + Returns: + { + 'vehicle': [{'time': float, 'x': float, 'y': float}, ...], + 'agents': { + 'ped1': [{'time': float, 'x': float, 'y': float}, ...], + 'ped2': [...], + ... + } + } + """ + try: + with open(file_path, "r") as f: + # Read file line by line to handle large files + positions = {"vehicle": [], "agents": {}} + + for line in f: + try: + entry = json.loads(line.strip()) + + # Process Vehicle State + if "vehicle" in entry: + vehicle_data = entry["vehicle"]["data"]["pose"] + positions["vehicle"].append( + { + "time": entry["time"], + "x": vehicle_data.get("x", 0), + "y": vehicle_data.get("y", 0), + } + ) + + # Process Agent States + if "agents" in entry: + for agent_name, agent_data in entry["agents"].items(): + if agent_name not in positions["agents"]: + positions["agents"][agent_name] = [] + + agent_pose = agent_data["data"]["pose"] + positions["agents"][agent_name].append( + { + "time": entry["time"], + "x": agent_pose.get("x", 0), + "y": agent_pose.get("y", 0), + } + ) + + except json.JSONDecodeError: + # Skip invalid JSON lines + continue + + return positions + except Exception as e: + print(f"Error parsing behavior data: {e}") + return None + + +@functools.lru_cache(maxsize=100) +def load_log_data(): + """Load all log data with caching""" + cache_key = "all_logs" + cached_logs = cache.get(cache_key) + + if cached_logs is not None: + return cached_logs + + start_time = time.time() + logs = [] + + for log_folder in sorted(os.listdir(LOG_DIR), reverse=True): + log_path = os.path.join(LOG_DIR, log_folder) + if not os.path.isdir(log_path): + continue + + meta_path = os.path.join(log_path, "meta.yaml") + settings_path = os.path.join(log_path, "settings.yaml") + + try: + with open(meta_path, "r") as meta_file: + meta_data = yaml.safe_load(meta_file) + with open(settings_path, "r") as settings_file: + settings_data = yaml.safe_load(settings_file).get("run", {}) + except Exception as e: + print(f"Error loading log data: {e}") + continue + + logs.append( + { + "date": log_folder, + "run_duration": meta_data.get("run_duration", "Unknown"), + "exit_reason": meta_data.get("exit_reason", "Unknown"), + "mode": settings_data.get("mode", "Unknown"), + "launch_command": settings_data.get("log", {}).get( + "launch_command", "Unknown" + ), + "folder": log_path, + } + ) + + # Store results in cache + cache.set(cache_key, logs) + end_time = time.time() + print(f"Log data loaded in {end_time - start_time:.2f} seconds") + print(logs) + return logs + + +def filter_logs_by_date(logs, start_date=None, end_date=None): + """Filter logs by date range""" + cache_key = get_cache_key("filtered_logs", start_date, end_date) + cached_result = cache.get(cache_key) + + if cached_result is not None: + return cached_result + + if start_date: + start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d") + if end_date: + end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d") + + filtered_logs = [] + for log in logs: + try: + log_date = datetime.datetime.strptime(log["date"][:10], "%Y-%m-%d") + if (not start_date or log_date >= start_date) and ( + not end_date or log_date <= end_date + ): + filtered_logs.append(log) + except ValueError: + # Skip logs with invalid date format + continue + + # Cache the filtered results + cache.set(cache_key, filtered_logs) + return filtered_logs + + +@functools.lru_cache(maxsize=50) +def get_log_metadata(log_folder_path): + """Get metadata for a specific log with caching""" + cache_key = f"metadata_{log_folder_path}" + cached_metadata = cache.get(cache_key) + + if cached_metadata is not None: + return cached_metadata + + metadata = {"folder": log_folder_path} + + # Read metadata from files + meta_path = os.path.join(log_folder_path, "meta.yaml") + settings_path = os.path.join(log_folder_path, "settings.yaml") + + try: + if os.path.exists(meta_path): + with open(meta_path, "r") as meta_file: + metadata.update(yaml.safe_load(meta_file)) + if os.path.exists(settings_path): + with open(settings_path, "r") as settings_file: + log_settings = yaml.safe_load(settings_file).get("run", {}) + + metadata.update( + { + "mode": log_settings.get("mode", "Unknown"), + "launch_command": log_settings.get("log", {}).get( + "launch_command", "Unknown" + ), + } + ) + except Exception as e: + print(f"Error loading metadata: {e}") + + # Cache the results + cache.set(cache_key, metadata) + return metadata + + +@app.route("/") +def index(): + logs = load_log_data() + return render_template("index.html", logs=logs) + + +@app.route("/filter_logs", methods=["POST"]) +def filter_logs(): + start_time = time.time() + logs = load_log_data() + data = request.json + start_date = data.get("start_date") + end_date = data.get("end_date") + + filtered_logs = filter_logs_by_date(logs, start_date, end_date) + + end_time = time.time() + print(f"Filtered logs in {end_time - start_time:.2f} seconds") + return jsonify(filtered_logs) + + +@app.route("/view_log/") +def view_log(log_folder): + log_folder_path = os.path.join(LOG_DIR, log_folder) + if not os.path.exists(log_folder_path): + return "Log folder not found!", 404 + + # Get metadata with caching + metadata = get_log_metadata(log_folder_path) + + # Get directory structure + files = sorted(os.listdir(log_folder_path)) + + return render_template( + "view_log.html", log_folder=log_folder, metadata=metadata, files=files + ) + + +@app.route("/open_folder/") +def open_folder(folder): + import os, platform + + full_path = os.path.abspath(os.path.join(LOG_DIR, folder)) + if not full_path.startswith(os.path.abspath(LOG_DIR)): + return "Invalid path", 400 + if not os.path.isdir(full_path): + return f"Folder does not exist: {folder}", 404 + + try: + if platform.system() == "Windows": + os.system(f'explorer "{full_path}"') + elif platform.system() == "Linux": + os.system(f'xdg-open "{full_path}"') + elif platform.system() == "Darwin": + os.system(f'open "{full_path}"') + else: + return "Unsupported platform", 400 + except Exception as e: + return f"Failed to open folder: {e}", 500 + + # if result != 0: + # return 'Failed to open folder', 500 + + return "Folder opened successfully.", 204 + + +@app.route("/view_file//") +def view_file(log_folder, filename): + file_path = os.path.join(LOG_DIR, log_folder, filename) + if not os.path.exists(file_path): + return jsonify({"error": "File not found!"}), 404 + + # Check file size first + file_size = os.path.getsize(file_path) + + # Define chunk size for pagination (50,000 lines or ~1MB) + CHUNK_SIZE = 1000 + + # Get page number from query parameter (default to 1) + page = request.args.get("page", 1, type=int) + + try: + with open(file_path, "r") as f: + # Skip lines for previous pages + for _ in range((page - 1) * CHUNK_SIZE): + f.readline() + + # Read next chunk of lines + lines = [f.readline() for _ in range(CHUNK_SIZE)] + # Check if there are more lines + has_more = bool(f.readline()) + + # Prepare response + return jsonify( + { + "filename": filename, + "content": "".join(lines), + "total_size": file_size, + "page": page, + "has_more": has_more, + } + ) + except UnicodeDecodeError: + return jsonify( + { + "filename": filename, + "content": "This file contains binary data and cannot be displayed in the browser.", + "total_size": file_size, + "page": page, + "has_more": False, + } + ) + + +@app.route("/raw_logs//") +def stream_raw_log(log_folder, filename): + file_path = os.path.join(LOG_DIR, log_folder, filename) + if not os.path.isfile(file_path): + return "File not found!", 404 + + def generate(): + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + yield line + + return Response(stream_with_context(generate()), mimetype="text/plain") + + +@app.route("/parse_behavior//") +def parse_behavior(log_folder, filename): + """ + Parse behavior.json and return structured position data + """ + file_path = os.path.join(LOG_DIR, log_folder, filename) + + if not os.path.exists(file_path): + return jsonify({"error": "File not found!"}), 404 + + # Parse behavior data + behavior_data = parse_behavior_data(file_path) + + if behavior_data is None: + return jsonify({"error": "Could not parse behavior data"}), 500 + + return jsonify(behavior_data) + + +# Clear cache after certain time period +@app.after_request +def add_header(response): + # Invalidate cache for certain requests + if request.path == "/": + # Clear cache periodically for main page + current_time = int(time.time()) + last_cleared = cache.get("last_cache_clear") or 0 + + if current_time - last_cleared > 300: # 5 minutes + # Reset log data cache + load_log_data.cache_clear() + cache.set("last_cache_clear", current_time) + + return response + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/log_dashboard/plots/.gitignore b/log_dashboard/plots/.gitignore new file mode 100644 index 000000000..9f992b4d7 --- /dev/null +++ b/log_dashboard/plots/.gitignore @@ -0,0 +1,5 @@ +* + +# track just these files +!.gitignore +!sample_plot.png diff --git a/log_dashboard/plots/sample_plot.png b/log_dashboard/plots/sample_plot.png new file mode 100644 index 000000000..9d1fac35d Binary files /dev/null and b/log_dashboard/plots/sample_plot.png differ diff --git a/log_dashboard/requirements.txt b/log_dashboard/requirements.txt new file mode 100644 index 000000000..cb006a29b --- /dev/null +++ b/log_dashboard/requirements.txt @@ -0,0 +1,5 @@ +flask +pyyaml +cachelib +numpy +matplotlib \ No newline at end of file diff --git a/log_dashboard/templates/index.html b/log_dashboard/templates/index.html new file mode 100644 index 000000000..d0401710b --- /dev/null +++ b/log_dashboard/templates/index.html @@ -0,0 +1,102 @@ + + + + + + GEMstack Log Dashboard + + + + + +
+

GEMstack Log Dashboard

+
+ + + + + +
+ + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
DateRun Duration (s)Termination ReasonSim vs RealLaunch CommandActions
{{ log.date }}{{ log.run_duration }}{{ log.exit_reason }}{{ log.mode }}{{ log.launch_command }} +
+ View + Details + +
+
+
+ + + + \ No newline at end of file diff --git a/log_dashboard/templates/render.html b/log_dashboard/templates/render.html new file mode 100644 index 000000000..71e46d98b --- /dev/null +++ b/log_dashboard/templates/render.html @@ -0,0 +1,75 @@ + + + + + Render Trajectory + + + + + + + + +
+

Rendered Trajectory

+ +
+ Image will be displayed here +
+ + +
+ + diff --git a/log_dashboard/templates/view_log.html b/log_dashboard/templates/view_log.html new file mode 100644 index 000000000..0d605a2f8 --- /dev/null +++ b/log_dashboard/templates/view_log.html @@ -0,0 +1,389 @@ + + + + + + View Log - {{ log_folder }} + + + + + + + + + +
+
+

Log Details for {{ log_folder }}

+
+ Back to Dashboard + +
+
+ +
+
+
+

Run Duration: {{ metadata.run_duration }}

+

Exit Reason: {{ metadata.exit_reason }}

+
+
+

Mode: {{ metadata.mode }}

+

Launch Command: {{ metadata.launch_command }}

+
+
+
+ +
+
+

Files

+
+ {% for file in files %} +
+ {% if file.endswith('.yaml') or file.endswith('.yml') %} + 📄 {{ file }} + {% elif file.endswith('.txt') or file.endswith('.log') %} + 📝 {{ file }} + {% elif file.endswith('.csv') %} + 📊 {{ file }} + {% else %} + 📄 {{ file }} + {% endif %} +
+ {% endfor %} +
+
+
+
+

Select a file to view

+
+
+
+
+
+ +
+
+
+
+ + + + + \ No newline at end of file