Files
traccar_animation/py_scripts/video_3d_generator.py
2025-07-07 12:20:16 +03:00

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