1233 lines
45 KiB
Python
1233 lines
45 KiB
Python
import os
|
|
import json
|
|
import numpy as np
|
|
from datetime import datetime, timedelta
|
|
import time
|
|
from PIL import Image
|
|
import cv2
|
|
from geopy.distance import geodesic
|
|
import math
|
|
|
|
# Optional advanced dependencies with fallback handling
|
|
try:
|
|
import pandas as pd
|
|
import geopandas as gpd
|
|
PANDAS_AVAILABLE = True
|
|
except ImportError:
|
|
PANDAS_AVAILABLE = False
|
|
print("Warning: pandas/geopandas not available. Install with: pip install pandas geopandas")
|
|
|
|
try:
|
|
import plotly.graph_objects as go
|
|
import plotly.express as px
|
|
from plotly.subplots import make_subplots
|
|
PLOTLY_AVAILABLE = True
|
|
except ImportError:
|
|
PLOTLY_AVAILABLE = False
|
|
print("Warning: plotly not available. Install with: pip install plotly")
|
|
|
|
try:
|
|
import pydeck as pdk
|
|
PYDECK_AVAILABLE = True
|
|
except ImportError:
|
|
PYDECK_AVAILABLE = False
|
|
print("Warning: pydeck not available. Install with: pip install pydeck")
|
|
|
|
try:
|
|
import cv2
|
|
OPENCV_AVAILABLE = True
|
|
except ImportError:
|
|
OPENCV_AVAILABLE = False
|
|
print("Warning: opencv-python not available. Install with: pip install opencv-python")
|
|
|
|
class NavigationAnimationGenerator:
|
|
"""
|
|
Professional navigation animation generator with satellite view and 3D camera following
|
|
Creates Google Earth-style entry scene and detailed terrain navigation
|
|
"""
|
|
|
|
def __init__(self, output_folder):
|
|
self.output_folder = output_folder
|
|
self.frames_folder = os.path.join(output_folder, "nav_frames")
|
|
self.temp_folder = os.path.join(output_folder, "temp")
|
|
|
|
# Create necessary folders
|
|
os.makedirs(self.frames_folder, exist_ok=True)
|
|
os.makedirs(self.temp_folder, exist_ok=True)
|
|
|
|
# Navigation animation settings
|
|
self.fps = 30
|
|
self.entry_duration = 4 # seconds for Google Earth entry
|
|
self.camera_height_min = 1000 # meters
|
|
self.camera_height_max = 2000 # meters
|
|
self.follow_distance = 500 # meters behind navigation point
|
|
|
|
def check_dependencies(self):
|
|
"""Check if all required dependencies are available"""
|
|
missing = []
|
|
if not PANDAS_AVAILABLE:
|
|
missing.append("pandas geopandas")
|
|
if not PLOTLY_AVAILABLE:
|
|
missing.append("plotly")
|
|
if not PYDECK_AVAILABLE:
|
|
missing.append("pydeck")
|
|
if not OPENCV_AVAILABLE:
|
|
missing.append("opencv-python")
|
|
|
|
if missing:
|
|
raise ImportError(f"Missing required dependencies: {', '.join(missing)}. Please install them with: pip install {' '.join(missing)}")
|
|
|
|
return True
|
|
|
|
def load_gps_data(self, positions_file):
|
|
"""Load and preprocess GPS data for navigation"""
|
|
with open(positions_file, 'r') as f:
|
|
positions = json.load(f)
|
|
|
|
# Convert to DataFrame
|
|
df = pd.DataFrame(positions)
|
|
|
|
# Parse timestamps
|
|
df['timestamp'] = pd.to_datetime(df['fixTime'])
|
|
df = df.sort_values('timestamp')
|
|
|
|
# Calculate speed and bearing
|
|
df['speed_kmh'] = df['speed'] * 1.852 # Convert knots to km/h
|
|
df['elevation'] = df.get('altitude', 100)
|
|
|
|
# Calculate bearings and distances
|
|
bearings = []
|
|
distances = []
|
|
|
|
for i in range(len(df)):
|
|
if i == 0:
|
|
bearings.append(0)
|
|
distances.append(0)
|
|
else:
|
|
# Calculate bearing to next point
|
|
lat1, lon1 = df.iloc[i-1]['latitude'], df.iloc[i-1]['longitude']
|
|
lat2, lon2 = df.iloc[i]['latitude'], df.iloc[i]['longitude']
|
|
|
|
bearing = self.calculate_bearing(lat1, lon1, lat2, lon2)
|
|
distance = geodesic((lat1, lon1), (lat2, lon2)).meters
|
|
|
|
bearings.append(bearing)
|
|
distances.append(distance)
|
|
|
|
df['bearing'] = bearings
|
|
df['distance'] = distances
|
|
|
|
return df
|
|
|
|
def calculate_bearing(self, lat1, lon1, lat2, lon2):
|
|
"""Calculate bearing between two GPS points"""
|
|
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
|
dlon = lon2 - lon1
|
|
|
|
y = math.sin(dlon) * math.cos(lat2)
|
|
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
|
|
|
bearing = math.atan2(y, x)
|
|
bearing = math.degrees(bearing)
|
|
bearing = (bearing + 360) % 360
|
|
|
|
return bearing
|
|
|
|
def create_google_earth_entry_scene(self, df, frame_num):
|
|
"""Create Google Earth-style entry scene (zooming in from space)"""
|
|
if not PLOTLY_AVAILABLE:
|
|
raise ImportError("Plotly is required for entry scene")
|
|
|
|
# Get route bounds
|
|
center_lat = df['latitude'].mean()
|
|
center_lon = df['longitude'].mean()
|
|
|
|
# Entry animation: start from very high altitude and zoom in
|
|
total_entry_frames = self.entry_duration * self.fps
|
|
zoom_progress = frame_num / total_entry_frames
|
|
|
|
# Camera altitude decreases exponentially
|
|
start_altitude = 50000 # Start from 50km
|
|
end_altitude = 3000 # End at 3km
|
|
current_altitude = start_altitude * (1 - zoom_progress) + end_altitude * zoom_progress
|
|
|
|
# Create figure
|
|
fig = go.Figure()
|
|
|
|
# Add Earth-like surface with satellite imagery simulation
|
|
terrain_size = 0.5 - (0.4 * zoom_progress) # Zoom in effect
|
|
resolution = int(30 + (50 * zoom_progress)) # More detail as we zoom
|
|
|
|
lat_range = np.linspace(center_lat - terrain_size, center_lat + terrain_size, resolution)
|
|
lon_range = np.linspace(center_lon - terrain_size, center_lon + terrain_size, resolution)
|
|
lat_mesh, lon_mesh = np.meshgrid(lat_range, lon_range)
|
|
|
|
# Generate satellite-like terrain
|
|
terrain_heights = self.generate_satellite_terrain(lat_mesh, lon_mesh, center_lat, center_lon)
|
|
|
|
# Add terrain surface
|
|
fig.add_trace(
|
|
go.Surface(
|
|
x=lon_mesh,
|
|
y=lat_mesh,
|
|
z=terrain_heights,
|
|
colorscale=[
|
|
[0.0, 'rgb(0,100,0)'], # Deep green (forests)
|
|
[0.2, 'rgb(34,139,34)'], # Forest green
|
|
[0.4, 'rgb(255,215,0)'], # Gold (fields)
|
|
[0.6, 'rgb(139,69,19)'], # Brown (earth)
|
|
[0.8, 'rgb(105,105,105)'], # Gray (rock)
|
|
[1.0, 'rgb(255,255,255)'] # White (snow)
|
|
],
|
|
opacity=0.95,
|
|
showscale=False,
|
|
lighting=dict(
|
|
ambient=0.4,
|
|
diffuse=0.8,
|
|
specular=0.2
|
|
)
|
|
)
|
|
)
|
|
|
|
# Show partial route (fading in)
|
|
route_alpha = min(1.0, zoom_progress * 2)
|
|
if route_alpha > 0:
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=df['longitude'],
|
|
y=df['latitude'],
|
|
z=df['elevation'] + 100,
|
|
mode='lines',
|
|
line=dict(
|
|
color='red',
|
|
width=8,
|
|
),
|
|
opacity=route_alpha,
|
|
name='Route'
|
|
)
|
|
)
|
|
|
|
# Camera position for entry effect
|
|
camera_distance = current_altitude / 10000
|
|
|
|
fig.update_layout(
|
|
title=dict(
|
|
text=f'Navigation Overview - Approaching Destination',
|
|
x=0.5,
|
|
font=dict(size=28, color='white', family="Arial Black")
|
|
),
|
|
scene=dict(
|
|
camera=dict(
|
|
eye=dict(x=0, y=-camera_distance, z=camera_distance),
|
|
center=dict(x=0, y=0, z=0),
|
|
up=dict(x=0, y=0, z=1)
|
|
),
|
|
xaxis=dict(visible=False),
|
|
yaxis=dict(visible=False),
|
|
zaxis=dict(visible=False),
|
|
aspectmode='cube',
|
|
bgcolor='rgb(0,0,50)', # Space-like background
|
|
),
|
|
paper_bgcolor='black',
|
|
showlegend=False,
|
|
width=1920,
|
|
height=1080,
|
|
margin=dict(l=0, r=0, t=60, b=0)
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"NavEntry_{frame_num:04d}.png")
|
|
fig.write_image(frame_path, engine="kaleido")
|
|
return frame_path
|
|
|
|
def create_navigation_frame(self, df, current_index, frame_num):
|
|
"""Create detailed navigation frame with 3D following camera"""
|
|
if not PLOTLY_AVAILABLE:
|
|
raise ImportError("Plotly is required for navigation frames")
|
|
|
|
current_row = df.iloc[current_index]
|
|
current_lat = current_row['latitude']
|
|
current_lon = current_row['longitude']
|
|
current_alt = current_row['elevation']
|
|
current_speed = current_row['speed_kmh']
|
|
current_bearing = current_row['bearing']
|
|
|
|
# Get route progress
|
|
completed_route = df.iloc[:current_index + 1]
|
|
remaining_route = df.iloc[current_index:]
|
|
|
|
# Create detailed terrain around current position
|
|
terrain_radius = 0.01 # degrees around current position
|
|
resolution = 60
|
|
|
|
lat_range = np.linspace(current_lat - terrain_radius, current_lat + terrain_radius, resolution)
|
|
lon_range = np.linspace(current_lon - terrain_radius, current_lon + terrain_radius, resolution)
|
|
lat_mesh, lon_mesh = np.meshgrid(lat_range, lon_range)
|
|
|
|
# Generate high-detail satellite terrain
|
|
terrain_heights = self.generate_detailed_terrain(lat_mesh, lon_mesh, current_lat, current_lon)
|
|
|
|
# Create navigation display figure
|
|
fig = go.Figure()
|
|
|
|
# Add detailed terrain
|
|
fig.add_trace(
|
|
go.Surface(
|
|
x=lon_mesh,
|
|
y=lat_mesh,
|
|
z=terrain_heights,
|
|
colorscale=[
|
|
[0.0, 'rgb(34,139,34)'], # Forest green
|
|
[0.2, 'rgb(107,142,35)'], # Olive drab
|
|
[0.4, 'rgb(255,215,0)'], # Gold (fields)
|
|
[0.5, 'rgb(210,180,140)'], # Tan (roads/clearings)
|
|
[0.7, 'rgb(139,69,19)'], # Brown (earth)
|
|
[0.9, 'rgb(105,105,105)'], # Gray (rock)
|
|
[1.0, 'rgb(255,255,255)'] # White (peaks)
|
|
],
|
|
opacity=0.9,
|
|
showscale=False,
|
|
lighting=dict(
|
|
ambient=0.3,
|
|
diffuse=0.9,
|
|
specular=0.1
|
|
)
|
|
)
|
|
)
|
|
|
|
# Add completed route (green)
|
|
if len(completed_route) > 1:
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=completed_route['longitude'],
|
|
y=completed_route['latitude'],
|
|
z=completed_route['elevation'] + 50,
|
|
mode='lines',
|
|
line=dict(color='lime', width=12),
|
|
name='Completed'
|
|
)
|
|
)
|
|
|
|
# Add remaining route (blue, semi-transparent)
|
|
if len(remaining_route) > 1:
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=remaining_route['longitude'],
|
|
y=remaining_route['latitude'],
|
|
z=remaining_route['elevation'] + 50,
|
|
mode='lines',
|
|
line=dict(color='cyan', width=8),
|
|
opacity=0.6,
|
|
name='Remaining'
|
|
)
|
|
)
|
|
|
|
# Add navigation point (current vehicle position)
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[current_lon],
|
|
y=[current_lat],
|
|
z=[current_alt + 100],
|
|
mode='markers',
|
|
marker=dict(
|
|
color='red',
|
|
size=25,
|
|
symbol='diamond',
|
|
line=dict(color='white', width=4)
|
|
),
|
|
name='Vehicle'
|
|
)
|
|
)
|
|
|
|
# Add direction indicator
|
|
bearing_rad = math.radians(current_bearing)
|
|
arrow_length = 0.002
|
|
arrow_end_lat = current_lat + arrow_length * math.cos(bearing_rad)
|
|
arrow_end_lon = current_lon + arrow_length * math.sin(bearing_rad)
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[current_lon, arrow_end_lon],
|
|
y=[current_lat, arrow_end_lat],
|
|
z=[current_alt + 120, current_alt + 120],
|
|
mode='lines',
|
|
line=dict(color='yellow', width=15),
|
|
name='Direction'
|
|
)
|
|
)
|
|
|
|
# Calculate dynamic camera position for 3D following
|
|
# Camera follows behind and above at specified height
|
|
camera_height = self.camera_height_min + (self.camera_height_max - self.camera_height_min) * (current_speed / 100)
|
|
follow_distance_deg = 0.005 # degrees behind vehicle
|
|
|
|
# Position camera behind vehicle based on bearing
|
|
camera_bearing = (current_bearing + 180) % 360 # Opposite direction
|
|
camera_bearing_rad = math.radians(camera_bearing)
|
|
|
|
camera_lat = current_lat + follow_distance_deg * math.cos(camera_bearing_rad)
|
|
camera_lon = current_lon + follow_distance_deg * math.sin(camera_bearing_rad)
|
|
|
|
# Calculate relative camera position
|
|
camera_eye_x = (camera_lon - current_lon) * 100
|
|
camera_eye_y = (camera_lat - current_lat) * 100
|
|
camera_eye_z = camera_height / 1000
|
|
|
|
# Navigation info overlay
|
|
total_distance = sum(df['distance'])
|
|
completed_distance = sum(completed_route['distance'])
|
|
progress_percent = (completed_distance / total_distance) * 100 if total_distance > 0 else 0
|
|
|
|
fig.update_layout(
|
|
title=dict(
|
|
text=f'Navigation • Speed: {current_speed:.1f} km/h • Progress: {progress_percent:.1f}% • {current_row["timestamp"].strftime("%H:%M:%S")}',
|
|
x=0.5,
|
|
font=dict(size=20, color='white', family="Arial Black")
|
|
),
|
|
scene=dict(
|
|
camera=dict(
|
|
eye=dict(x=camera_eye_x, y=camera_eye_y, z=camera_eye_z),
|
|
center=dict(x=0, y=0, z=0),
|
|
up=dict(x=0, y=0, z=1)
|
|
),
|
|
xaxis=dict(visible=False),
|
|
yaxis=dict(visible=False),
|
|
zaxis=dict(visible=False),
|
|
aspectmode='manual',
|
|
aspectratio=dict(x=1, y=1, z=0.3),
|
|
bgcolor='rgb(135,206,235)' # Sky blue
|
|
),
|
|
paper_bgcolor='black',
|
|
showlegend=False,
|
|
width=1920,
|
|
height=1080,
|
|
margin=dict(l=0, r=0, t=50, b=0)
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"Navigation_{frame_num:04d}.png")
|
|
fig.write_image(frame_path, engine="kaleido")
|
|
return frame_path
|
|
|
|
def generate_satellite_terrain(self, lat_mesh, lon_mesh, center_lat, center_lon):
|
|
"""Generate satellite-view realistic terrain for entry scene"""
|
|
# Convert to local coordinates
|
|
lat_m = (lat_mesh - center_lat) * 111000
|
|
lon_m = (lon_mesh - center_lon) * 111000 * np.cos(np.radians(center_lat))
|
|
|
|
# Base elevation with realistic variation
|
|
base_height = 200 + 50 * np.sin(lat_m / 5000) * np.cos(lon_m / 3000)
|
|
|
|
# Mountain ranges
|
|
mountains = 800 * np.exp(-((lat_m - 2000)**2 + (lon_m - 1000)**2) / (3000**2))
|
|
mountains += 600 * np.exp(-((lat_m + 1500)**2 + (lon_m + 2000)**2) / (2500**2))
|
|
|
|
# Hills and valleys
|
|
hills = 200 * np.sin(lat_m / 1000) * np.cos(lon_m / 1200)
|
|
valleys = -100 * np.exp(-((lat_m)**2 + (lon_m)**2) / (2000**2))
|
|
|
|
terrain = base_height + mountains + hills + valleys
|
|
return np.maximum(terrain, 50)
|
|
|
|
def generate_detailed_terrain(self, lat_mesh, lon_mesh, center_lat, center_lon):
|
|
"""Generate high-detail terrain for navigation view"""
|
|
# Convert to local coordinates
|
|
lat_m = (lat_mesh - center_lat) * 111000
|
|
lon_m = (lon_mesh - center_lon) * 111000 * np.cos(np.radians(center_lat))
|
|
|
|
# Base terrain
|
|
base = 150 + 30 * np.sin(lat_m / 500) * np.cos(lon_m / 400)
|
|
|
|
# Local features
|
|
hills = 100 * np.exp(-((lat_m - 300)**2 + (lon_m - 200)**2) / (200**2))
|
|
ridges = 80 * np.exp(-((lat_m + 200)**2 + (lon_m - 400)**2) / (300**2))
|
|
|
|
# Fine detail
|
|
detail = 20 * np.sin(lat_m / 50) * np.cos(lon_m / 60)
|
|
|
|
terrain = base + hills + ridges + detail
|
|
return np.maximum(terrain, 30)
|
|
|
|
for i in range(len(df)):
|
|
if i == 0:
|
|
distances.append(0)
|
|
bearings.append(0)
|
|
else:
|
|
prev_point = (df.iloc[i-1]['latitude'], df.iloc[i-1]['longitude'])
|
|
curr_point = (df.iloc[i]['latitude'], df.iloc[i]['longitude'])
|
|
|
|
# Calculate distance
|
|
dist = geodesic(prev_point, curr_point).meters
|
|
distances.append(dist)
|
|
|
|
# Calculate bearing
|
|
lat1, lon1 = math.radians(prev_point[0]), math.radians(prev_point[1])
|
|
lat2, lon2 = math.radians(curr_point[0]), math.radians(curr_point[1])
|
|
|
|
dlon = lon2 - lon1
|
|
bearing = math.atan2(
|
|
math.sin(dlon) * math.cos(lat2),
|
|
math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
|
)
|
|
bearings.append(math.degrees(bearing))
|
|
|
|
df['distance'] = distances
|
|
df['bearing'] = bearings
|
|
|
|
return df
|
|
|
|
def create_pydeck_frame(self, df, current_index, frame_num):
|
|
"""Create a single frame using Pydeck"""
|
|
# Get current position
|
|
current_row = df.iloc[current_index]
|
|
|
|
# Get trail data (previous points)
|
|
start_idx = max(0, current_index - self.trail_length)
|
|
trail_data = df.iloc[start_idx:current_index + 1].copy()
|
|
|
|
# Create color gradient for trail (fade effect)
|
|
trail_colors = []
|
|
for i, _ in enumerate(trail_data.iterrows()):
|
|
alpha = (i + 1) / len(trail_data) * 255
|
|
trail_colors.append([255, 100, 100, int(alpha)])
|
|
|
|
trail_data['color'] = trail_colors
|
|
|
|
# Create the deck
|
|
view_state = pdk.ViewState(
|
|
longitude=current_row['longitude'],
|
|
latitude=current_row['latitude'],
|
|
zoom=16,
|
|
pitch=60,
|
|
bearing=current_row['bearing']
|
|
)
|
|
|
|
# Path layer (trail)
|
|
path_layer = pdk.Layer(
|
|
"PathLayer",
|
|
trail_data,
|
|
get_path=lambda x: [[x['longitude'], x['latitude'], x['elevation']]],
|
|
get_color=[255, 100, 100, 200],
|
|
width_min_pixels=3,
|
|
width_max_pixels=8,
|
|
)
|
|
|
|
# Scatterplot layer (current position)
|
|
current_point = pd.DataFrame([{
|
|
'longitude': current_row['longitude'],
|
|
'latitude': current_row['latitude'],
|
|
'elevation': current_row['elevation'] + 10,
|
|
'speed': current_row['speed_kmh']
|
|
}])
|
|
|
|
scatter_layer = pdk.Layer(
|
|
"ScatterplotLayer",
|
|
current_point,
|
|
get_position=['longitude', 'latitude', 'elevation'],
|
|
get_radius=15,
|
|
get_color=[255, 255, 0, 255],
|
|
pickable=True
|
|
)
|
|
|
|
# Create deck
|
|
deck = pdk.Deck(
|
|
layers=[path_layer, scatter_layer],
|
|
initial_view_state=view_state,
|
|
map_style='mapbox://styles/mapbox/satellite-v9'
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"frame_{frame_num:06d}.png")
|
|
deck.to_html(frame_path.replace('.png', '.html'))
|
|
|
|
return frame_path
|
|
|
|
def create_plotly_frame(self, df, current_index, frame_num):
|
|
"""Create a single frame using Plotly for 3D visualization"""
|
|
# Get current position
|
|
current_row = df.iloc[current_index]
|
|
|
|
# Get trail data
|
|
start_idx = max(0, current_index - self.trail_length)
|
|
trail_data = df.iloc[start_idx:current_index + 1]
|
|
|
|
# Create 3D scatter plot
|
|
fig = go.Figure()
|
|
|
|
# Add trail
|
|
fig.add_trace(go.Scatter3d(
|
|
x=trail_data['longitude'],
|
|
y=trail_data['latitude'],
|
|
z=trail_data['elevation'],
|
|
mode='lines+markers',
|
|
line=dict(
|
|
color=trail_data.index,
|
|
colorscale='Plasma',
|
|
width=8
|
|
),
|
|
marker=dict(
|
|
size=3,
|
|
opacity=0.8
|
|
),
|
|
name='Trail'
|
|
))
|
|
|
|
# Add current position
|
|
fig.add_trace(go.Scatter3d(
|
|
x=[current_row['longitude']],
|
|
y=[current_row['latitude']],
|
|
z=[current_row['elevation'] + 50],
|
|
mode='markers',
|
|
marker=dict(
|
|
size=15,
|
|
color='red',
|
|
symbol='diamond'
|
|
),
|
|
name='Current Position'
|
|
))
|
|
|
|
# Add speed information as text
|
|
speed_text = f"Speed: {current_row['speed_kmh']:.1f} km/h<br>"
|
|
speed_text += f"Time: {current_row['timestamp'].strftime('%H:%M:%S')}<br>"
|
|
speed_text += f"Altitude: {current_row['elevation']:.0f} m"
|
|
|
|
# Update layout
|
|
fig.update_layout(
|
|
title=f"3D GPS Track Animation - Frame {frame_num}",
|
|
scene=dict(
|
|
xaxis_title='Longitude',
|
|
yaxis_title='Latitude',
|
|
zaxis_title='Elevation (m)',
|
|
camera=dict(
|
|
eye=dict(x=1.5, y=1.5, z=1.5)
|
|
),
|
|
aspectmode='cube'
|
|
),
|
|
annotations=[
|
|
dict(
|
|
text=speed_text,
|
|
x=0.02,
|
|
y=0.98,
|
|
xref='paper',
|
|
yref='paper',
|
|
showarrow=False,
|
|
bgcolor='rgba(255,255,255,0.8)',
|
|
bordercolor='black',
|
|
borderwidth=1
|
|
)
|
|
],
|
|
width=1920,
|
|
height=1080
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"frame_{frame_num:06d}.png")
|
|
fig.write_image(frame_path, engine="kaleido")
|
|
|
|
return frame_path
|
|
|
|
def create_advanced_plotly_frame(self, df, current_index, frame_num):
|
|
"""Create Relive-style progressive animation frame"""
|
|
# Progressive track: show all points from start to current position
|
|
current_track = df.iloc[:current_index + 1]
|
|
current_row = df.iloc[current_index]
|
|
|
|
# Create subplot with multiple views
|
|
fig = make_subplots(
|
|
rows=2, cols=2,
|
|
subplot_titles=[
|
|
f'3D Track Progress - Frame {frame_num + 1}',
|
|
'Route Overview',
|
|
'Speed Over Time',
|
|
'Elevation Profile'
|
|
],
|
|
specs=[[{'type': 'scene'}, {'type': 'scatter'}],
|
|
[{'type': 'scatter'}, {'type': 'scatter'}]],
|
|
vertical_spacing=0.1,
|
|
horizontal_spacing=0.1
|
|
)
|
|
|
|
# 1. 3D Progressive Track View
|
|
if len(current_track) > 1:
|
|
# Show completed track in blue
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=current_track['longitude'],
|
|
y=current_track['latitude'],
|
|
z=current_track['elevation'],
|
|
mode='lines+markers',
|
|
line=dict(color='blue', width=5),
|
|
marker=dict(
|
|
size=3,
|
|
color=current_track['speed_kmh'],
|
|
colorscale='Viridis',
|
|
showscale=True,
|
|
colorbar=dict(title="Speed (km/h)", x=0.45)
|
|
),
|
|
name='Completed Track',
|
|
showlegend=False
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
# Current vehicle position (moving marker)
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[current_row['longitude']],
|
|
y=[current_row['latitude']],
|
|
z=[current_row['elevation'] + 15],
|
|
mode='markers',
|
|
marker=dict(
|
|
size=15,
|
|
color='red',
|
|
symbol='diamond',
|
|
line=dict(color='white', width=2)
|
|
),
|
|
name=f'Vehicle - {current_row["timestamp"].strftime("%H:%M:%S")}',
|
|
showlegend=False
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
# 2. Top View - Route Overview with full track
|
|
# Show full route in light gray
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df['longitude'],
|
|
y=df['latitude'],
|
|
mode='lines',
|
|
line=dict(color='lightgray', width=2, dash='dot'),
|
|
name='Full Route',
|
|
showlegend=False
|
|
),
|
|
row=1, col=2
|
|
)
|
|
|
|
# Show completed track in color
|
|
if len(current_track) > 1:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=current_track['longitude'],
|
|
y=current_track['latitude'],
|
|
mode='lines+markers',
|
|
line=dict(color='blue', width=4),
|
|
marker=dict(
|
|
size=4,
|
|
color=current_track['speed_kmh'],
|
|
colorscale='Viridis'
|
|
),
|
|
name='Progress',
|
|
showlegend=False
|
|
),
|
|
row=1, col=2
|
|
)
|
|
|
|
# Current position on map
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[current_row['longitude']],
|
|
y=[current_row['latitude']],
|
|
mode='markers',
|
|
marker=dict(
|
|
size=12,
|
|
color='red',
|
|
symbol='circle',
|
|
line=dict(color='white', width=3)
|
|
),
|
|
name='Current Position',
|
|
showlegend=False
|
|
),
|
|
row=1, col=2
|
|
)
|
|
|
|
# 3. Speed Profile Over Time
|
|
if len(current_track) > 1:
|
|
time_points = list(range(len(current_track)))
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=time_points,
|
|
y=current_track['speed_kmh'],
|
|
mode='lines+markers',
|
|
line=dict(color='green', width=3),
|
|
marker=dict(size=4),
|
|
fill='tonexty',
|
|
name='Speed',
|
|
showlegend=False
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
# Current speed marker
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[current_index],
|
|
y=[current_row['speed_kmh']],
|
|
mode='markers',
|
|
marker=dict(size=10, color='red'),
|
|
name='Current Speed',
|
|
showlegend=False
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
# 4. Elevation Profile Over Time
|
|
if len(current_track) > 1:
|
|
time_points = list(range(len(current_track)))
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=time_points,
|
|
y=current_track['elevation'],
|
|
mode='lines+markers',
|
|
line=dict(color='brown', width=3),
|
|
marker=dict(size=4),
|
|
fill='tonexty',
|
|
name='Elevation',
|
|
showlegend=False
|
|
),
|
|
row=2, col=2
|
|
)
|
|
|
|
# Current elevation marker
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[current_index],
|
|
y=[current_row['elevation']],
|
|
mode='markers',
|
|
marker=dict(size=10, color='red'),
|
|
name='Current Elevation',
|
|
showlegend=False
|
|
),
|
|
row=2, col=2
|
|
)
|
|
|
|
# Enhanced layout with better styling
|
|
fig.update_layout(
|
|
title=dict(
|
|
text=f'GPS Track Animation - {current_row["timestamp"].strftime("%Y-%m-%d %H:%M:%S")}<br>' +
|
|
f'Progress: {current_index + 1}/{len(df)} points ({((current_index + 1)/len(df)*100):.1f}%)',
|
|
x=0.5,
|
|
font=dict(size=16)
|
|
),
|
|
showlegend=False,
|
|
width=1920,
|
|
height=1080,
|
|
paper_bgcolor='white',
|
|
plot_bgcolor='white'
|
|
)
|
|
|
|
# Update 3D scene
|
|
fig.update_scenes(
|
|
camera=dict(
|
|
eye=dict(x=1.5, y=1.5, z=1.2),
|
|
center=dict(x=0, y=0, z=0),
|
|
up=dict(x=0, y=0, z=1)
|
|
),
|
|
aspectmode='cube'
|
|
)
|
|
|
|
# Update axes labels
|
|
fig.update_xaxes(title_text="Longitude", row=1, col=2)
|
|
fig.update_yaxes(title_text="Latitude", row=1, col=2)
|
|
fig.update_xaxes(title_text="Time Points", row=2, col=1)
|
|
fig.update_yaxes(title_text="Speed (km/h)", row=2, col=1)
|
|
fig.update_xaxes(title_text="Time Points", row=2, col=2)
|
|
fig.update_yaxes(title_text="Elevation (m)", row=2, col=2)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"advanced_3d_frame_{frame_num:04d}.png")
|
|
|
|
try:
|
|
fig.write_image(frame_path, engine="kaleido", width=1920, height=1080)
|
|
return frame_path
|
|
except Exception as e:
|
|
print(f"Error creating frame {frame_num}: {e}")
|
|
return None
|
|
def generate_frames(self, positions_file, style='advanced', progress_callback=None):
|
|
"""Generate Relive-style progressive animation frames"""
|
|
print("Loading GPS data...")
|
|
df = self.load_gps_data(positions_file)
|
|
|
|
if len(df) < 2:
|
|
print("Not enough GPS points for animation")
|
|
return []
|
|
|
|
# Animation settings for smooth progression
|
|
min_frames = 60 # Minimum frames for very short trips
|
|
max_frames = 300 # Maximum frames to keep file size reasonable
|
|
|
|
# Calculate optimal frame count based on trip length
|
|
total_frames = min(max_frames, max(min_frames, len(df) * 2))
|
|
|
|
# Calculate step size for smooth progression
|
|
step_size = len(df) / total_frames
|
|
|
|
frame_paths = []
|
|
|
|
print(f"Generating {total_frames} frames for Relive-style animation...")
|
|
print(f"Processing {len(df)} GPS points with step size {step_size:.2f}")
|
|
|
|
for frame_num in range(total_frames):
|
|
# Calculate which GPS point to show up to
|
|
current_index = min(int(frame_num * step_size), len(df) - 1)
|
|
|
|
# Ensure we always progress forward
|
|
if current_index == 0:
|
|
current_index = 1
|
|
|
|
try:
|
|
if style == 'pydeck':
|
|
frame_path = self.create_pydeck_frame(df, current_index, frame_num)
|
|
elif style == 'plotly':
|
|
frame_path = self.create_plotly_frame(df, current_index, frame_num)
|
|
elif style == 'google_earth':
|
|
frame_path = self.create_google_earth_frame(df, current_index, frame_num)
|
|
else: # advanced (default)
|
|
frame_path = self.create_advanced_plotly_frame(df, current_index, frame_num)
|
|
|
|
if frame_path:
|
|
frame_paths.append(frame_path)
|
|
|
|
# Update progress
|
|
if progress_callback:
|
|
progress = ((frame_num + 1) / total_frames) * 100
|
|
progress_callback(
|
|
progress,
|
|
f"Creating animation frame {frame_num + 1}/{total_frames} (GPS point {current_index + 1}/{len(df)})"
|
|
)
|
|
|
|
# Progress feedback
|
|
if (frame_num + 1) % 20 == 0:
|
|
print(f"Generated {frame_num + 1}/{total_frames} frames ({progress:.1f}%)")
|
|
|
|
except Exception as e:
|
|
print(f"Error generating frame {frame_num}: {e}")
|
|
continue
|
|
|
|
print(f"Successfully generated {len(frame_paths)} animation frames")
|
|
return frame_paths
|
|
|
|
def create_video(self, frame_paths, output_video_path, progress_callback=None):
|
|
"""Create video from frames using OpenCV for better compatibility"""
|
|
print("Creating navigation animation video...")
|
|
|
|
if not frame_paths:
|
|
print("No frames to create video from")
|
|
return False
|
|
|
|
try:
|
|
import cv2
|
|
|
|
# Filter out None paths
|
|
valid_frames = [f for f in frame_paths if f and os.path.exists(f)]
|
|
|
|
if not valid_frames:
|
|
print("No valid frames found")
|
|
return False
|
|
|
|
print(f"Creating video from {len(valid_frames)} frames at {self.fps} FPS...")
|
|
|
|
# Update progress
|
|
if progress_callback:
|
|
progress_callback(30, "Reading frame dimensions...")
|
|
|
|
# Read first frame to get dimensions
|
|
first_frame = cv2.imread(valid_frames[0])
|
|
if first_frame is None:
|
|
print("Error reading first frame")
|
|
return False
|
|
|
|
height, width, layers = first_frame.shape
|
|
|
|
# Update progress
|
|
if progress_callback:
|
|
progress_callback(40, "Setting up video encoder...")
|
|
|
|
# Create video writer with OpenCV
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v') # You can also try 'XVID'
|
|
video_writer = cv2.VideoWriter(output_video_path, fourcc, self.fps, (width, height))
|
|
|
|
if not video_writer.isOpened():
|
|
print("Error: Could not open video writer")
|
|
return False
|
|
|
|
# Update progress
|
|
if progress_callback:
|
|
progress_callback(50, "Writing frames to video...")
|
|
|
|
# Write frames to video
|
|
total_frames = len(valid_frames)
|
|
for i, frame_path in enumerate(valid_frames):
|
|
frame = cv2.imread(frame_path)
|
|
if frame is not None:
|
|
video_writer.write(frame)
|
|
|
|
# Update progress periodically
|
|
if progress_callback and i % 10 == 0:
|
|
progress_percent = 50 + (i / total_frames) * 40 # 50-90%
|
|
progress_callback(progress_percent, f"Writing frame {i+1}/{total_frames}...")
|
|
|
|
# Clean up
|
|
video_writer.release()
|
|
cv2.destroyAllWindows()
|
|
|
|
if progress_callback:
|
|
progress_callback(100, f"Video successfully created: {os.path.basename(output_video_path)}")
|
|
|
|
# Verify the video was created
|
|
if os.path.exists(output_video_path) and os.path.getsize(output_video_path) > 0:
|
|
print(f"✅ Navigation animation video saved to: {output_video_path}")
|
|
file_size = os.path.getsize(output_video_path) / (1024 * 1024) # MB
|
|
print(f"📊 Video info: {len(valid_frames)} frames, {self.fps} FPS, {file_size:.1f} MB")
|
|
return True
|
|
else:
|
|
print("Error: Video file was not created properly")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Error creating video: {e}")
|
|
if progress_callback:
|
|
progress_callback(-1, f"Error: {e}")
|
|
return False
|
|
|
|
def cleanup_frames(self):
|
|
"""Clean up temporary frame files"""
|
|
import shutil
|
|
if os.path.exists(self.frames_folder):
|
|
shutil.rmtree(self.frames_folder)
|
|
os.makedirs(self.frames_folder, exist_ok=True)
|
|
|
|
|
|
|
|
def create_google_earth_frame(self, df, current_index, frame_num):
|
|
"""
|
|
Create a Google Earth-style flythrough frame with realistic terrain and camera following
|
|
"""
|
|
if not PLOTLY_AVAILABLE:
|
|
raise ImportError("Plotly is required for Google Earth-style frames")
|
|
|
|
# Get current position and context
|
|
current_row = df.iloc[current_index]
|
|
current_lat = current_row['latitude']
|
|
current_lon = current_row['longitude']
|
|
current_alt = current_row.get('elevation', 100)
|
|
|
|
# Get track up to current position (progressive reveal)
|
|
track_so_far = df.iloc[:current_index + 1]
|
|
|
|
# Calculate terrain bounds around the track
|
|
lat_margin = 0.02 # degrees
|
|
lon_margin = 0.02 # degrees
|
|
min_lat = track_so_far['latitude'].min() - lat_margin
|
|
max_lat = track_so_far['latitude'].max() + lat_margin
|
|
min_lon = track_so_far['longitude'].min() - lon_margin
|
|
max_lon = track_so_far['longitude'].max() + lon_margin
|
|
|
|
# Generate terrain mesh
|
|
terrain_resolution = 40
|
|
lat_range = np.linspace(min_lat, max_lat, terrain_resolution)
|
|
lon_range = np.linspace(min_lon, max_lon, terrain_resolution)
|
|
lat_mesh, lon_mesh = np.meshgrid(lat_range, lon_range)
|
|
|
|
# Generate realistic terrain heights
|
|
terrain_heights = self.generate_terrain_heights(lat_mesh, lon_mesh, current_lat, current_lon)
|
|
|
|
# Create the figure
|
|
fig = go.Figure()
|
|
|
|
# Add terrain surface
|
|
fig.add_trace(
|
|
go.Surface(
|
|
x=lon_mesh,
|
|
y=lat_mesh,
|
|
z=terrain_heights,
|
|
colorscale=[
|
|
[0.0, 'rgb(139,69,19)'], # Brown (low elevation)
|
|
[0.2, 'rgb(160,82,45)'], # Saddle brown
|
|
[0.4, 'rgb(107,142,35)'], # Olive drab (medium)
|
|
[0.6, 'rgb(34,139,34)'], # Forest green
|
|
[0.8, 'rgb(105,105,105)'], # Dim gray (high)
|
|
[1.0, 'rgb(255,255,255)'] # White (peaks)
|
|
],
|
|
opacity=0.9,
|
|
showscale=False,
|
|
name='Terrain',
|
|
lighting=dict(
|
|
ambient=0.3,
|
|
diffuse=0.8,
|
|
specular=0.3,
|
|
roughness=0.3
|
|
)
|
|
)
|
|
)
|
|
|
|
# Add GPS track so far (elevated above terrain)
|
|
if len(track_so_far) > 1:
|
|
track_elevation = track_so_far['elevation'].values + 80 # 80m above terrain
|
|
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=track_so_far['longitude'],
|
|
y=track_so_far['latitude'],
|
|
z=track_elevation,
|
|
mode='lines+markers',
|
|
line=dict(
|
|
color='red',
|
|
width=10
|
|
),
|
|
marker=dict(
|
|
size=4,
|
|
color='orange',
|
|
opacity=0.8
|
|
),
|
|
name='GPS Track'
|
|
)
|
|
)
|
|
|
|
# Add current vehicle position
|
|
vehicle_height = current_alt + 120 # Above terrain
|
|
fig.add_trace(
|
|
go.Scatter3d(
|
|
x=[current_lon],
|
|
y=[current_lat],
|
|
z=[vehicle_height],
|
|
mode='markers',
|
|
marker=dict(
|
|
color='red',
|
|
size=20,
|
|
symbol='diamond',
|
|
line=dict(color='yellow', width=3)
|
|
),
|
|
name='Vehicle'
|
|
)
|
|
)
|
|
|
|
# Calculate dynamic camera position for cinematic following
|
|
# Camera follows behind and above the vehicle
|
|
follow_distance = 0.005 # degrees behind
|
|
camera_height_offset = 1000 # meters above vehicle
|
|
|
|
if current_index > 5:
|
|
# Calculate movement direction from last few points
|
|
recent_track = df.iloc[max(0, current_index-5):current_index+1]
|
|
lat_direction = recent_track['latitude'].iloc[-1] - recent_track['latitude'].iloc[0]
|
|
lon_direction = recent_track['longitude'].iloc[-1] - recent_track['longitude'].iloc[0]
|
|
|
|
# Normalize direction
|
|
direction_length = np.sqrt(lat_direction**2 + lon_direction**2)
|
|
if direction_length > 0:
|
|
lat_direction /= direction_length
|
|
lon_direction /= direction_length
|
|
|
|
# Position camera behind the vehicle
|
|
camera_lat = current_lat - lat_direction * follow_distance
|
|
camera_lon = current_lon - lon_direction * follow_distance
|
|
else:
|
|
camera_lat = current_lat - follow_distance
|
|
camera_lon = current_lon - follow_distance
|
|
|
|
camera_z = (current_alt + camera_height_offset) / 1000 # Convert to relative scale
|
|
|
|
# Update layout for cinematic Google Earth-style view
|
|
fig.update_layout(
|
|
title=dict(
|
|
text=f'GPS Flythrough - {current_row["timestamp"].strftime("%H:%M:%S")} - Frame {frame_num}',
|
|
x=0.5,
|
|
font=dict(size=24, color='white', family="Arial Black")
|
|
),
|
|
scene=dict(
|
|
camera=dict(
|
|
eye=dict(
|
|
x=1.2, # Camera position relative to scene
|
|
y=-1.5,
|
|
z=0.8
|
|
),
|
|
center=dict(
|
|
x=0,
|
|
y=0,
|
|
z=0.2
|
|
),
|
|
up=dict(x=0, y=0, z=1)
|
|
),
|
|
xaxis=dict(
|
|
title='',
|
|
showgrid=False,
|
|
zeroline=False,
|
|
showline=False,
|
|
showticklabels=False,
|
|
showbackground=False
|
|
),
|
|
yaxis=dict(
|
|
title='',
|
|
showgrid=False,
|
|
zeroline=False,
|
|
showline=False,
|
|
showticklabels=False,
|
|
showbackground=False
|
|
),
|
|
zaxis=dict(
|
|
title='',
|
|
showgrid=False,
|
|
zeroline=False,
|
|
showline=False,
|
|
showticklabels=False,
|
|
showbackground=False
|
|
),
|
|
aspectmode='cube',
|
|
bgcolor='rgb(135,206,235)', # Sky blue background
|
|
camera_projection_type='perspective'
|
|
),
|
|
paper_bgcolor='rgb(0,0,0)',
|
|
plot_bgcolor='rgb(0,0,0)',
|
|
showlegend=False,
|
|
width=1920,
|
|
height=1080,
|
|
margin=dict(l=0, r=0, t=60, b=0),
|
|
font=dict(color='white')
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(self.frames_folder, f"GoogleEarth_Frame_{frame_num:04d}.png")
|
|
try:
|
|
fig.write_image(frame_path, engine="kaleido", width=1920, height=1080)
|
|
return frame_path
|
|
except Exception as e:
|
|
print(f"Error saving frame {frame_num}: {e}")
|
|
return None
|
|
|
|
def generate_terrain_heights(self, lat_mesh, lon_mesh, center_lat, center_lon):
|
|
"""
|
|
Generate realistic terrain heights using mathematical functions to simulate mountains/hills
|
|
"""
|
|
# Convert lat/lon to local coordinates (approximate)
|
|
lat_m = (lat_mesh - center_lat) * 111000 # degrees to meters
|
|
lon_m = (lon_mesh - center_lon) * 111000 * np.cos(np.radians(center_lat))
|
|
|
|
# Base terrain height
|
|
base_height = 300
|
|
|
|
# Create mountain ridges using sine waves
|
|
ridge1 = 400 * np.exp(-((lat_m - 1000)**2 + (lon_m + 500)**2) / (800**2))
|
|
ridge2 = 500 * np.exp(-((lat_m + 800)**2 + (lon_m - 1200)**2) / (1000**2))
|
|
ridge3 = 350 * np.exp(-((lat_m)**2 + (lon_m)**2) / (1500**2))
|
|
|
|
# Add rolling hills
|
|
hills = 150 * np.sin(lat_m / 400) * np.cos(lon_m / 600)
|
|
hills += 100 * np.sin(lat_m / 800) * np.sin(lon_m / 300)
|
|
|
|
# Add valleys
|
|
valleys = -80 * np.exp(-((lat_m - 500)**2 + (lon_m + 800)**2) / (600**2))
|
|
|
|
# Combine all terrain features
|
|
terrain = base_height + ridge1 + ridge2 + ridge3 + hills + valleys
|
|
|
|
# Add some noise for realism
|
|
noise = 30 * np.sin(lat_m / 100) * np.cos(lon_m / 150)
|
|
terrain += noise
|
|
|
|
# Ensure minimum elevation
|
|
terrain = np.maximum(terrain, 50)
|
|
|
|
return terrain
|
|
|
|
|