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: from moviepy import VideoFileClip, ImageSequenceClip MOVIEPY_AVAILABLE = True except ImportError: MOVIEPY_AVAILABLE = False print("Warning: moviepy not available. Install with: pip install moviepy") class Advanced3DGenerator: """ Advanced 3D animation generator using Pydeck + Plotly + Blender pipeline for high-quality GPS track visualizations """ def __init__(self, output_folder): self.output_folder = output_folder self.frames_folder = os.path.join(output_folder, "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) # Animation settings self.fps = 30 self.duration_per_point = 0.5 # seconds per GPS point self.camera_height = 1000 # meters self.trail_length = 50 # number of previous points to show 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 MOVIEPY_AVAILABLE: missing.append("moviepy") 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""" 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', 0) # Calculate distance between points distances = [] bearings = [] 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
" speed_text += f"Time: {current_row['timestamp'].strftime('%H:%M:%S')}
" 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")}
' + 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 MoviePy with optimized settings""" print("Creating Relive-style animation video...") if not frame_paths: print("No frames to create video from") return False try: # 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...") # Create video clip from images with optimal settings clip = ImageSequenceClip(valid_frames, fps=self.fps) # Add smooth fade effects for professional look clip = clip.fadein(0.5).fadeout(0.5) # Update progress if progress_callback: progress_callback(50, "Encoding video with optimized settings...") # Write video file with high quality settings clip.write_videofile( output_video_path, codec='libx264', audio=False, temp_audiofile=None, remove_temp=True, verbose=False, logger=None, bitrate="8000k", # High quality bitrate ffmpeg_params=[ "-preset", "medium", # Balance between speed and compression "-crf", "18", # High quality (lower = better quality) "-pix_fmt", "yuv420p" # Better compatibility ] ) if progress_callback: progress_callback(100, f"Video successfully created: {os.path.basename(output_video_path)}") print(f"✅ Relive-style animation video saved to: {output_video_path}") print(f"📊 Video info: {len(valid_frames)} frames, {self.fps} FPS, {clip.duration:.1f}s duration") # Clean up clip clip.close() return True except Exception as e: print(f"Error creating video: {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 generate_3d_animation(self, positions_file, output_video_path, style='advanced', cleanup=True, progress_callback=None): """ Main method to generate 3D animation Args: positions_file: Path to JSON file with GPS positions output_video_path: Path for output video style: 'pydeck', 'plotly', or 'advanced' cleanup: Whether to clean up temporary files progress_callback: Callback function for progress updates """ try: # Generate frames frame_paths = self.generate_frames(positions_file, style, progress_callback) if not frame_paths: raise Exception("No frames generated") # Create video success = self.create_video(frame_paths, output_video_path, progress_callback) if cleanup: self.cleanup_frames() return success except Exception as e: print(f"Error generating 3D animation: {e}") if progress_callback: progress_callback(-1, f"Error: {e}") return False 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 def generate_advanced_3d_video(positions_file, output_folder, filename_prefix="advanced_3d", style='advanced', progress_callback=None): """ Convenience function to generate advanced 3D video """ generator = Advanced3DGenerator(output_folder) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_video_path = os.path.join(output_folder, f"{filename_prefix}_{timestamp}.mp4") success = generator.generate_3d_animation( positions_file, output_video_path, style=style, progress_callback=progress_callback ) return output_video_path if success else None # Test function if __name__ == "__main__": # Test the advanced 3D generator test_positions = "test_positions.json" output_dir = "test_output" def test_progress(progress, message): print(f"Progress: {progress:.1f}% - {message}") video_path = generate_advanced_3d_video( test_positions, output_dir, style='advanced', progress_callback=test_progress ) if video_path: print(f"Test video created: {video_path}") else: print("Test failed")