297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""
|
|
3D Video Animation Generator
|
|
Creates Relive-style 3D video animations from GPS route data
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import math
|
|
import requests
|
|
import cv2
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import tempfile
|
|
import shutil
|
|
from datetime import datetime
|
|
|
|
def generate_3d_video_animation(project_name, resources_folder, label_widget, progress_widget, popup_widget, clock_module):
|
|
"""
|
|
Generate a 3D video animation similar to Relive
|
|
|
|
Args:
|
|
project_name: Name of the project
|
|
resources_folder: Path to resources folder
|
|
label_widget: Kivy label for status updates
|
|
progress_widget: Kivy progress bar
|
|
popup_widget: Kivy popup to dismiss when done
|
|
clock_module: Kivy Clock module for scheduling
|
|
"""
|
|
|
|
def update_progress(progress_val, status_text):
|
|
"""Update UI from background thread"""
|
|
def _update(dt):
|
|
progress_widget.value = progress_val
|
|
label_widget.text = status_text
|
|
clock_module.schedule_once(_update, 0)
|
|
|
|
def finish_generation(success, message, output_path=None):
|
|
"""Finish the generation process"""
|
|
def _finish(dt):
|
|
if popup_widget:
|
|
popup_widget.dismiss()
|
|
|
|
# Show result popup
|
|
from kivy.uix.popup import Popup
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.label import Label
|
|
|
|
result_layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
|
|
|
if success:
|
|
result_label = Label(
|
|
text=f"3D Video Generated Successfully!\n\nSaved to:\n{output_path}",
|
|
color=(0, 1, 0, 1),
|
|
halign="center"
|
|
)
|
|
open_btn = Button(
|
|
text="Open Video Folder",
|
|
size_hint_y=None,
|
|
height=40,
|
|
background_color=(0.2, 0.7, 0.2, 1)
|
|
)
|
|
open_btn.bind(on_press=lambda x: (os.system(f"xdg-open '{os.path.dirname(output_path)}'"), result_popup.dismiss()))
|
|
result_layout.add_widget(result_label)
|
|
result_layout.add_widget(open_btn)
|
|
else:
|
|
result_label = Label(
|
|
text=f"Generation Failed:\n{message}",
|
|
color=(1, 0, 0, 1),
|
|
halign="center"
|
|
)
|
|
result_layout.add_widget(result_label)
|
|
|
|
close_btn = Button(
|
|
text="Close",
|
|
size_hint_y=None,
|
|
height=40,
|
|
background_color=(0.3, 0.3, 0.3, 1)
|
|
)
|
|
|
|
result_layout.add_widget(close_btn)
|
|
|
|
result_popup = Popup(
|
|
title="3D Video Generation Result",
|
|
content=result_layout,
|
|
size_hint=(0.9, 0.6),
|
|
auto_dismiss=False
|
|
)
|
|
|
|
close_btn.bind(on_press=lambda x: result_popup.dismiss())
|
|
result_popup.open()
|
|
|
|
clock_module.schedule_once(_finish, 0)
|
|
|
|
def run_generation():
|
|
"""Main generation function"""
|
|
try:
|
|
# Step 1: Load route data
|
|
update_progress(10, "Loading route data...")
|
|
project_folder = os.path.join(resources_folder, "projects", project_name)
|
|
positions_path = os.path.join(project_folder, "positions.json")
|
|
|
|
if not os.path.exists(positions_path):
|
|
finish_generation(False, "No route data found!")
|
|
return
|
|
|
|
with open(positions_path, "r") as f:
|
|
positions = json.load(f)
|
|
|
|
if len(positions) < 10:
|
|
finish_generation(False, "Route too short for 3D animation (minimum 10 points)")
|
|
return
|
|
|
|
# Step 2: Calculate route bounds and center
|
|
update_progress(20, "Calculating route boundaries...")
|
|
lats = [pos['latitude'] for pos in positions]
|
|
lons = [pos['longitude'] for pos in positions]
|
|
|
|
center_lat = sum(lats) / len(lats)
|
|
center_lon = sum(lons) / len(lons)
|
|
|
|
min_lat, max_lat = min(lats), max(lats)
|
|
min_lon, max_lon = min(lons), max(lons)
|
|
|
|
# Step 3: Generate frames
|
|
update_progress(30, "Generating 3D frames...")
|
|
|
|
# Create temporary directory for frames
|
|
temp_dir = tempfile.mkdtemp()
|
|
frames_dir = os.path.join(temp_dir, "frames")
|
|
os.makedirs(frames_dir)
|
|
|
|
# Video settings
|
|
width, height = 1920, 1080
|
|
fps = 30
|
|
total_frames = len(positions) * 2 # 2 frames per position for smooth animation
|
|
|
|
# Generate frames
|
|
for i, pos in enumerate(positions):
|
|
progress = 30 + (i / len(positions)) * 40
|
|
update_progress(progress, f"Generating frame {i+1}/{len(positions)}...")
|
|
|
|
frame = create_3d_frame(
|
|
pos, positions, i, center_lat, center_lon,
|
|
min_lat, max_lat, min_lon, max_lon,
|
|
width, height
|
|
)
|
|
|
|
# Save frame
|
|
frame_path = os.path.join(frames_dir, f"frame_{i:06d}.png")
|
|
cv2.imwrite(frame_path, frame)
|
|
|
|
# Step 4: Create video
|
|
update_progress(75, "Compiling video...")
|
|
|
|
# Output path
|
|
output_filename = f"{project_name}_3d_animation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4"
|
|
output_path = os.path.join(project_folder, output_filename)
|
|
|
|
# Create video writer
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
video_writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
|
|
|
# Add frames to video
|
|
frame_files = sorted([f for f in os.listdir(frames_dir) if f.endswith('.png')])
|
|
for frame_file in frame_files:
|
|
frame_path = os.path.join(frames_dir, frame_file)
|
|
frame = cv2.imread(frame_path)
|
|
video_writer.write(frame)
|
|
|
|
video_writer.release()
|
|
|
|
# Step 5: Add audio (optional)
|
|
update_progress(90, "Adding finishing touches...")
|
|
|
|
# Clean up
|
|
shutil.rmtree(temp_dir)
|
|
|
|
update_progress(100, "3D Video generated successfully!")
|
|
finish_generation(True, "Success!", output_path)
|
|
|
|
except Exception as e:
|
|
finish_generation(False, str(e))
|
|
|
|
# Start generation in background
|
|
import threading
|
|
thread = threading.Thread(target=run_generation)
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
def create_3d_frame(current_pos, all_positions, frame_index, center_lat, center_lon,
|
|
min_lat, max_lat, min_lon, max_lon, width, height):
|
|
"""
|
|
Create a single 3D-style frame
|
|
"""
|
|
# Create canvas
|
|
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
|
|
|
# Background gradient (sky effect)
|
|
for y in range(height):
|
|
color_intensity = int(255 * (1 - y / height))
|
|
sky_color = (min(255, color_intensity + 50), min(255, color_intensity + 100), 255)
|
|
frame[y, :] = sky_color
|
|
|
|
# Calculate perspective transformation
|
|
# Simple isometric-style projection
|
|
scale_x = width * 0.6 / (max_lon - min_lon) if max_lon != min_lon else 1000
|
|
scale_y = height * 0.6 / (max_lat - min_lat) if max_lat != min_lat else 1000
|
|
|
|
# Draw route path with 3D effect
|
|
route_points = []
|
|
for i, pos in enumerate(all_positions[:frame_index + 1]):
|
|
# Convert GPS to screen coordinates
|
|
x = int((pos['longitude'] - min_lon) * scale_x + width * 0.2)
|
|
y = int(height * 0.8 - (pos['latitude'] - min_lat) * scale_y)
|
|
|
|
# Add 3D effect (elevation simulation)
|
|
elevation_offset = int(20 * math.sin(i * 0.1)) # Simulated elevation
|
|
y -= elevation_offset
|
|
|
|
route_points.append((x, y))
|
|
|
|
# Draw route trail with gradient
|
|
if len(route_points) > 1:
|
|
for i in range(1, len(route_points)):
|
|
# Color gradient from blue to red
|
|
progress = i / len(route_points)
|
|
color_r = int(255 * progress)
|
|
color_b = int(255 * (1 - progress))
|
|
color = (color_b, 100, color_r)
|
|
|
|
# Draw thick line with 3D shadow effect
|
|
pt1, pt2 = route_points[i-1], route_points[i]
|
|
|
|
# Shadow
|
|
cv2.line(frame, (pt1[0]+2, pt1[1]+2), (pt2[0]+2, pt2[1]+2), (50, 50, 50), 8)
|
|
# Main line
|
|
cv2.line(frame, pt1, pt2, color, 6)
|
|
|
|
# Draw current position marker
|
|
if route_points:
|
|
current_point = route_points[-1]
|
|
# Pulsing effect
|
|
pulse_size = int(15 + 10 * math.sin(frame_index * 0.3))
|
|
|
|
# Shadow
|
|
cv2.circle(frame, (current_point[0]+3, current_point[1]+3), pulse_size, (0, 0, 0), -1)
|
|
# Main marker
|
|
cv2.circle(frame, current_point, pulse_size, (0, 255, 255), -1)
|
|
cv2.circle(frame, current_point, pulse_size-3, (255, 255, 255), 2)
|
|
|
|
# Add grid effect for 3D feel
|
|
grid_spacing = 50
|
|
for x in range(0, width, grid_spacing):
|
|
cv2.line(frame, (x, 0), (x, height), (100, 100, 100), 1)
|
|
for y in range(0, height, grid_spacing):
|
|
cv2.line(frame, (0, y), (width, y), (100, 100, 100), 1)
|
|
|
|
# Add text overlay
|
|
try:
|
|
# Position info
|
|
speed = current_pos.get('speed', 0) if current_pos else 0
|
|
timestamp = current_pos.get('deviceTime', '') if current_pos else ''
|
|
|
|
text_y = 50
|
|
cv2.putText(frame, f"Speed: {speed:.1f} km/h", (50, text_y),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
|
|
|
text_y += 40
|
|
if timestamp:
|
|
cv2.putText(frame, f"Time: {timestamp[:16]}", (50, text_y),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
|
|
|
|
text_y += 40
|
|
cv2.putText(frame, f"Point: {frame_index + 1}/{len(all_positions)}", (50, text_y),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
|
|
|
|
except Exception:
|
|
pass # Skip text if font issues
|
|
|
|
return frame
|
|
|
|
def get_elevation_data(lat, lon):
|
|
"""
|
|
Get elevation data for a coordinate (optional enhancement)
|
|
"""
|
|
try:
|
|
# Using a free elevation API
|
|
url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
|
|
response = requests.get(url, timeout=5)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return data['results'][0]['elevation']
|
|
except Exception:
|
|
pass
|
|
return 0 # Default elevation
|