Compare commits
23 Commits
fca26d6557
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df9de12ce | |||
| 9f8c1c27dc | |||
| 1d0dc05a7b | |||
| 911143dfc5 | |||
| 29fd68f732 | |||
| 4fa7ed2a48 | |||
| 35d3bb8442 | |||
| 507f526433 | |||
| a565cd67e1 | |||
| 2532bf6219 | |||
| a38e2b1fe9 | |||
| c28be4e083 | |||
| 291e5bab44 | |||
| 3ccbf72599 | |||
| cb632752a3 | |||
| 0cc77fd89a | |||
| 069227abf9 | |||
| 6cac2381cd | |||
| fa3a11ee4b | |||
| 0ebdbc6b74 | |||
| 6240042901 | |||
| 5627c790f5 | |||
| 86d81d4501 |
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,2 +1,39 @@
|
||||
# Ignore the virtual environment folder
|
||||
track/
|
||||
track/
|
||||
|
||||
# Ignore Python cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Ignore project data and generated files
|
||||
resources/projects/
|
||||
resources/trip_archive/
|
||||
resources/credentials.enc
|
||||
resources/key.key
|
||||
resources/server_settings.enc
|
||||
|
||||
# Ignore generated videos and frames
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.webm
|
||||
cinema_frames/
|
||||
progressive_frames/
|
||||
|
||||
# Ignore test files and temporary files
|
||||
test_*.py
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Ignore IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Ignore OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
Binary file not shown.
Binary file not shown.
13
config.py
Normal file
13
config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.clock import Clock
|
||||
import os
|
||||
import json
|
||||
|
||||
RESOURCES_FOLDER = "resources"
|
||||
CREDENTIALS_FILE = f"{RESOURCES_FOLDER}/credentials.enc"
|
||||
805
main.py
805
main.py
@@ -5,7 +5,7 @@ import os
|
||||
import json
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, ListProperty
|
||||
from utils import (
|
||||
from py_scripts.utils import (
|
||||
generate_key, load_key, encrypt_data, decrypt_data,
|
||||
check_server_settings, save_server_settings,
|
||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||
@@ -21,797 +21,23 @@ from kivy.clock import mainthread
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
|
||||
import webbrowser
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from PIL import Image
|
||||
import time
|
||||
from screens.home_screen import HomeScreen
|
||||
from screens.login_screen import LoginScreen
|
||||
from screens.get_trip_from_server import GetTripFromServer
|
||||
from screens.create_animation_screen import CreateAnimationScreen
|
||||
from screens.settings_screen import SettingsScreen
|
||||
from screens.settings_screen import RegisterScreen
|
||||
from screens.pause_edit_screen_improved import PauseEditScreen
|
||||
from config import RESOURCES_FOLDER
|
||||
kivy.require("2.0.0")
|
||||
from kivy.core.window import Window
|
||||
Window.size = (360, 780)
|
||||
|
||||
RESOURCES_FOLDER = "resources"
|
||||
CREDENTIALS_FILE = os.path.join(RESOURCES_FOLDER, "credentials.enc")
|
||||
|
||||
|
||||
class LoginScreen(Screen):
|
||||
def login(self):
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.username_input.text.strip()
|
||||
|
||||
if not username or not password:
|
||||
self.manager.get_screen("home").ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
|
||||
credentials = {"username": username, "password": password}
|
||||
encrypted_data = encrypt_data(json.dumps(credentials))
|
||||
with open(CREDENTIALS_FILE, "wb") as file:
|
||||
file.write(encrypted_data)
|
||||
|
||||
self.manager.current = "home"
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
server_response = "Waiting to test connection..."
|
||||
|
||||
def on_pre_enter(self):
|
||||
settings = check_server_settings()
|
||||
if settings:
|
||||
self.ids.server_url_input.text = settings.get("server_url", "")
|
||||
self.ids.username_input.text = settings.get("username", "")
|
||||
self.ids.password_input.text = settings.get("password", "")
|
||||
self.ids.token_input.text = settings.get("token", "")
|
||||
else:
|
||||
self.ids.server_url_input.text = ""
|
||||
self.ids.username_input.text = ""
|
||||
self.ids.password_input.text = ""
|
||||
self.ids.token_input.text = ""
|
||||
|
||||
def test_connection(self):
|
||||
server_url = self.ids.server_url_input.text.strip()
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.password_input.text.strip()
|
||||
token = self.ids.token_input.text.strip()
|
||||
result = test_connection(server_url, username, password, token)
|
||||
self.server_response = result["message"]
|
||||
self.ids.result_label.text = self.server_response
|
||||
|
||||
def save_settings(self):
|
||||
server_url = self.ids.server_url_input.text.strip()
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.password_input.text.strip()
|
||||
token = self.ids.token_input.text.strip()
|
||||
if not server_url or not username or not password or not token:
|
||||
self.ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
settings_data = {
|
||||
"server_url": server_url,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"token": token,
|
||||
}
|
||||
try:
|
||||
save_server_settings(settings_data)
|
||||
self.ids.result_label.text = "Settings saved successfully!"
|
||||
self.manager.current = "home"
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Failed to save settings: {str(e)}"
|
||||
# get trip from server screen
|
||||
|
||||
class GetTripFromServer(Screen):
|
||||
server_info_text = StringProperty("LOADING DATA...")
|
||||
server_box_color = ListProperty([0.984, 0.553, 0.078, 1])
|
||||
device_mapping = {}
|
||||
|
||||
def on_pre_enter(self):
|
||||
self.server_box_color = [0.984, 0.553, 0.078, 1]
|
||||
self.server_info_text = "LOADING DATA..."
|
||||
Clock.schedule_once(self.check_server_settings, 1)
|
||||
|
||||
def check_server_settings(self, dt):
|
||||
settings = check_server_settings()
|
||||
if settings:
|
||||
server_url = settings["server_url"]
|
||||
username = settings["username"]
|
||||
password = settings["password"]
|
||||
token = settings["token"]
|
||||
self.server_info_text = f"CHECKING server: {server_url}"
|
||||
self.server_box_color = [0.984, 0.553, 0.078, 1]
|
||||
self.ids.devices_spinner.text = "Loading devices..."
|
||||
Clock.schedule_once(lambda dt: self.test_connection(server_url, username, password, token), 1)
|
||||
else:
|
||||
self.server_info_text = "Go to settings and set Traccar Server"
|
||||
self.server_box_color = [0.909, 0.031, 0.243, 1]
|
||||
self.ids.devices_spinner.text = "No devices available"
|
||||
|
||||
def set_result_message(self, message, color=(1, 1, 1, 1)):
|
||||
self.ids.result_label.text = message
|
||||
self.ids.result_label.color = color
|
||||
|
||||
def update_devices_spinner(self, devices):
|
||||
if devices:
|
||||
self.device_mapping = devices
|
||||
device_names = list(devices.keys())
|
||||
self.ids.devices_spinner.values = device_names
|
||||
self.ids.devices_spinner.text = "Select a device"
|
||||
else:
|
||||
self.ids.devices_spinner.text = "No devices found"
|
||||
self.ids.devices_spinner.values = []
|
||||
|
||||
def test_connection(self, server_url, username, password, token):
|
||||
result = test_connection(server_url, username, password, token)
|
||||
if result["status"]:
|
||||
self.server_info_text = f"Connected to {server_url}"
|
||||
self.server_box_color = [0.008, 0.525, 0.290, 1]
|
||||
devices = get_devices_from_server()
|
||||
self.update_devices_spinner(devices)
|
||||
else:
|
||||
self.server_info_text = result["message"]
|
||||
self.server_box_color = [0.909, 0.031, 0.243, 1]
|
||||
|
||||
def on_device_selected(self, device_name):
|
||||
if device_name != "Loading devices..." and device_name != "No devices found":
|
||||
self.ids.devices_spinner.background_color = (0.008, 0.525, 0.290, 1)
|
||||
else:
|
||||
self.ids.devices_spinner.background_color = (1, 1, 1, 1)
|
||||
|
||||
def open_date_picker(self, which):
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.popup import Popup
|
||||
|
||||
import calendar
|
||||
today = date.today()
|
||||
selected = {"year": today.year, "month": today.month}
|
||||
|
||||
def update_grid():
|
||||
grid.clear_widgets()
|
||||
days_in_month = calendar.monthrange(selected["year"], selected["month"])[1]
|
||||
for day in range(1, days_in_month + 1):
|
||||
btn = Button(
|
||||
text=str(day),
|
||||
size_hint=(None, None),
|
||||
size=(38, 38),
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=15
|
||||
)
|
||||
def on_date_selected(instance, day=day):
|
||||
date_str = f"{selected['year']}-{selected['month']:02d}-{day:02d}"
|
||||
if which == 'start':
|
||||
self.ids.start_date_picker_button.text = date_str
|
||||
else:
|
||||
self.ids.end_date_picker_button.text = date_str
|
||||
popup.dismiss()
|
||||
btn.bind(on_press=on_date_selected)
|
||||
grid.add_widget(btn)
|
||||
|
||||
def prev_month(instance):
|
||||
if selected["month"] > 1:
|
||||
selected["month"] -= 1
|
||||
else:
|
||||
selected["year"] -= 1
|
||||
selected["month"] = 12
|
||||
# Don't allow future months
|
||||
if (selected["year"], selected["month"]) > (today.year, today.month):
|
||||
selected["year"], selected["month"] = today.year, today.month
|
||||
title_label.text = f"Select a Date ({selected['year']}-{selected['month']:02d})"
|
||||
update_grid()
|
||||
|
||||
def next_month(instance):
|
||||
# Only allow up to current month
|
||||
if (selected["year"], selected["month"]) < (today.year, today.month):
|
||||
if selected["month"] < 12:
|
||||
selected["month"] += 1
|
||||
else:
|
||||
selected["year"] += 1
|
||||
selected["month"] = 1
|
||||
title_label.text = f"Select a Date ({selected['year']}-{selected['month']:02d})"
|
||||
update_grid()
|
||||
|
||||
def on_cancel(instance):
|
||||
popup.dismiss()
|
||||
|
||||
# Main vertical layout
|
||||
main_layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
# Month navigation row
|
||||
nav_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=40, spacing=10)
|
||||
prev_btn = Button(text="<", size_hint_x=None, width=40, font_size=18, background_color=(0.341, 0.235, 0.980, 1), color=(1,1,1,1))
|
||||
next_btn = Button(text=">", size_hint_x=None, width=40, font_size=18, background_color=(0.341, 0.235, 0.980, 1), color=(1,1,1,1))
|
||||
title_label = Label(
|
||||
text=f"Select a Date ({selected['year']}-{selected['month']:02d})",
|
||||
size_hint_x=1,
|
||||
font_size=18,
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
prev_btn.bind(on_press=prev_month)
|
||||
next_btn.bind(on_press=next_month)
|
||||
nav_layout.add_widget(prev_btn)
|
||||
nav_layout.add_widget(title_label)
|
||||
nav_layout.add_widget(next_btn)
|
||||
main_layout.add_widget(nav_layout)
|
||||
|
||||
# Grid of days: 6 columns
|
||||
grid = GridLayout(cols=6, spacing=6, size_hint_y=None)
|
||||
grid.bind(minimum_height=grid.setter('height'))
|
||||
main_layout.add_widget(grid)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
background_color=(0.909, 0.031, 0.243, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16
|
||||
)
|
||||
cancel_btn.bind(on_press=on_cancel)
|
||||
main_layout.add_widget(cancel_btn)
|
||||
|
||||
popup = Popup(
|
||||
title="",
|
||||
content=main_layout,
|
||||
size_hint=(0.95, 0.8),
|
||||
background_color=(0.11, 0.10, 0.15, 1),
|
||||
separator_height=0,
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
update_grid()
|
||||
popup.open()
|
||||
|
||||
def open_hour_picker(self, which):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
|
||||
popup_layout = BoxLayout(orientation='vertical', spacing=8, padding=8)
|
||||
scroll = ScrollView(size_hint=(1, 1))
|
||||
grid = GridLayout(cols=1, size_hint_y=None, spacing=4)
|
||||
grid.bind(minimum_height=grid.setter('height'))
|
||||
|
||||
# Add hour buttons
|
||||
for h in range(24):
|
||||
hour_str = f"{h:02d}"
|
||||
btn = Button(
|
||||
text=hour_str,
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
font_size=18,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
def set_hour(instance, hour=hour_str):
|
||||
if which == 'start':
|
||||
self.ids.start_hour_button.text = hour
|
||||
else:
|
||||
self.ids.end_hour_button.text = hour
|
||||
popup.dismiss()
|
||||
btn.bind(on_press=set_hour)
|
||||
grid.add_widget(btn)
|
||||
|
||||
scroll.add_widget(grid)
|
||||
popup_layout.add_widget(scroll)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
background_color=(0.909, 0.031, 0.243, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16
|
||||
)
|
||||
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup_layout.add_widget(cancel_btn)
|
||||
|
||||
popup = Popup(
|
||||
title="Select Hour",
|
||||
content=popup_layout,
|
||||
size_hint=(0.6, 0.7),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def update_points_count(self, count):
|
||||
"""Update the label showing the number of points."""
|
||||
self.ids.points_count_label.text = f"Points: {count}"
|
||||
|
||||
def save_route(self):
|
||||
"""Save the current list of positions as a route in resources/projects/<route_name>/positions.json."""
|
||||
route_name = self.ids.route_name_input.text.strip()
|
||||
positions = getattr(self, "last_positions", None)
|
||||
|
||||
success, message, file_path = save_route_to_file(route_name, positions)
|
||||
self.ids.result_label.text = message
|
||||
|
||||
if success:
|
||||
# Reset UI fields
|
||||
self.ids.devices_spinner.text = "Select a device"
|
||||
self.ids.start_date_picker_button.text = "Select Start Date"
|
||||
self.ids.end_date_picker_button.text = "Select End Date"
|
||||
self.ids.start_hour_button.text = "00"
|
||||
self.ids.end_hour_button.text = "23"
|
||||
self.ids.points_count_label.text = "Points: 0"
|
||||
self.ids.route_name_input.text = ""
|
||||
|
||||
# Show popup and schedule reroute to home
|
||||
popup = Popup(title="Success",
|
||||
content=Label(text=f"Route '{route_name}' saved!"),
|
||||
size_hint=(None, None), size=(300, 150),
|
||||
auto_dismiss=False)
|
||||
popup.open()
|
||||
def close_and_go_home(dt):
|
||||
popup.dismiss()
|
||||
self.manager.current = "home"
|
||||
Clock.schedule_once(close_and_go_home, 3)
|
||||
|
||||
def get_trip_server_data(self):
|
||||
"""Handle the Get trip server data button press."""
|
||||
selected_device = self.ids.devices_spinner.text
|
||||
start_date = self.ids.start_date_picker_button.text
|
||||
end_date = self.ids.end_date_picker_button.text
|
||||
|
||||
if selected_device == "Loading devices..." or selected_device == "No devices found":
|
||||
print("No valid device selected.")
|
||||
self.ids.result_label.text = "Please select a valid device."
|
||||
return
|
||||
|
||||
if start_date == "Select Date" or end_date == "Select Date":
|
||||
print("No valid date selected.")
|
||||
self.ids.result_label.text = "Please select valid start and end dates."
|
||||
return
|
||||
|
||||
# Fetch trip data from the server
|
||||
print(f"Fetching trip data for device: {selected_device} from {start_date} to {end_date}")
|
||||
self.ids.result_label.text = f"Fetching trip data for {selected_device} from {start_date} to {end_date}..."
|
||||
|
||||
positions = self.fetch_positions_for_selected_day()
|
||||
self.last_positions = positions # Store for saving
|
||||
self.update_points_count(len(positions) if positions else 0)
|
||||
if positions:
|
||||
print("Positions received:")
|
||||
for pos in positions:
|
||||
print(f"{pos['deviceTime']}: {pos['latitude']}, {pos['longitude']}")
|
||||
else:
|
||||
print("No positions found or error occurred.")
|
||||
|
||||
def fetch_positions_for_selected_day(self):
|
||||
settings = check_server_settings()
|
||||
device_name = self.ids.devices_spinner.text
|
||||
start_date = self.ids.start_date_picker_button.text
|
||||
end_date = self.ids.end_date_picker_button.text
|
||||
start_hour = self.ids.start_hour_button.text
|
||||
end_hour = self.ids.end_hour_button.text
|
||||
|
||||
positions, error = fetch_positions_for_selected_day(
|
||||
settings,
|
||||
self.device_mapping,
|
||||
device_name,
|
||||
start_date,
|
||||
end_date,
|
||||
start_hour,
|
||||
end_hour
|
||||
)
|
||||
if error:
|
||||
self.ids.result_label.text = error
|
||||
return []
|
||||
self.ids.result_label.text = f"Retrieved {len(positions)} positions."
|
||||
return positions
|
||||
|
||||
def fetch_devices_async(self):
|
||||
Thread(target=self._fetch_devices_worker).start()
|
||||
|
||||
def _fetch_devices_worker(self):
|
||||
devices = get_devices_from_server()
|
||||
self.update_devices_spinner_mainthread(devices)
|
||||
|
||||
@mainthread
|
||||
def update_devices_spinner_mainthread(self, devices):
|
||||
self.update_devices_spinner(devices)
|
||||
|
||||
|
||||
|
||||
# trhis screen is used to create a new user
|
||||
# register screen
|
||||
class RegisterScreen(Screen):
|
||||
def create_user(self):
|
||||
"""Handle user creation."""
|
||||
username = self.ids.set_username_input.text.strip()
|
||||
password = self.ids.set_password_input.text.strip()
|
||||
confirm_password = self.ids.confirm_password_input.text.strip()
|
||||
email = self.ids.set_email_input.text.strip()
|
||||
|
||||
if not username or not password or not confirm_password or not email:
|
||||
self.ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
|
||||
if password != confirm_password:
|
||||
self.ids.result_label.text = "Passwords do not match."
|
||||
return
|
||||
|
||||
# Check if the username or email already exists
|
||||
if self.user_exists(username, email):
|
||||
self.ids.result_label.text = "User or email already exists."
|
||||
return
|
||||
|
||||
# Save user data (encrypted)
|
||||
user_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"email": email,
|
||||
}
|
||||
encrypted_data = encrypt_data(json.dumps(user_data))
|
||||
|
||||
try:
|
||||
with open(CREDENTIALS_FILE, "ab") as file: # Append encrypted data
|
||||
file.write(encrypted_data + b"\n") # Add a newline for separation
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Failed to save user: {str(e)}"
|
||||
return
|
||||
|
||||
self.ids.result_label.text = "User created successfully!"
|
||||
# Navigate back to the login screen
|
||||
self.manager.current = "login"
|
||||
def user_exists(self, username, email):
|
||||
"""Check if a username or email already exists in the credentials.enc file."""
|
||||
try:
|
||||
with open(CREDENTIALS_FILE, "rb") as file:
|
||||
for line in file:
|
||||
decrypted_data = decrypt_data(line.strip())
|
||||
user = json.loads(decrypted_data)
|
||||
if user["username"] == username or user["email"] == email:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Error checking user: {str(e)}"
|
||||
return False
|
||||
|
||||
class CreateAnimationScreen(Screen):
|
||||
project_name = StringProperty("")
|
||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||
|
||||
def on_pre_enter(self):
|
||||
# Update the route entries label with the actual number of entries
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
count = 0
|
||||
if os.path.exists(positions_path):
|
||||
with open(positions_path, "r") as f:
|
||||
try:
|
||||
positions = json.load(f)
|
||||
count = len(positions)
|
||||
except Exception:
|
||||
count = 0
|
||||
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||
|
||||
def open_rename_popup(self):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Enter new project name:")
|
||||
input_field = TextInput(text=self.project_name, multiline=False)
|
||||
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(input_field)
|
||||
layout.add_widget(btn_save)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
popup = Popup(title="Rename Project", content=layout, size_hint=(None, None), size=(350, 260), auto_dismiss=False)
|
||||
|
||||
def do_rename(instance):
|
||||
new_name = input_field.text.strip()
|
||||
if new_name and new_name != self.project_name:
|
||||
if self.rename_project_folder(self.project_name, new_name):
|
||||
self.project_name = new_name
|
||||
popup.dismiss()
|
||||
|
||||
btn_save.bind(on_press=do_rename)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def rename_project_folder(self, old_name, new_name):
|
||||
import os
|
||||
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||
os.rename(old_path, new_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def optimize_route_entries(self):
|
||||
# Show popup with progress bar
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route entries...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Optimizing Route",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def process_entries(dt):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
# Detect duplicate positions at the start
|
||||
start_remove = 0
|
||||
if positions:
|
||||
first = positions[0]
|
||||
for pos in positions:
|
||||
if pos['latitude'] == first['latitude'] and pos['longitude'] == first['longitude']:
|
||||
start_remove += 1
|
||||
else:
|
||||
break
|
||||
if start_remove > 0:
|
||||
start_remove -= 1
|
||||
|
||||
# Detect duplicate positions at the end
|
||||
end_remove = 0
|
||||
if positions:
|
||||
last = positions[-1]
|
||||
for pos in reversed(positions):
|
||||
if pos['latitude'] == last['latitude'] and pos['longitude'] == last['longitude']:
|
||||
end_remove += 1
|
||||
else:
|
||||
break
|
||||
if end_remove > 0:
|
||||
end_remove -= 1
|
||||
|
||||
progress.value = 100
|
||||
label.text = (
|
||||
f"Entries removable at start: {start_remove}\n"
|
||||
f"Entries removable at end: {end_remove}"
|
||||
)
|
||||
|
||||
btn_save = Button(text="Save optimized file", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
btn_box = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=44)
|
||||
btn_box.add_widget(btn_save)
|
||||
btn_box.add_widget(btn_cancel)
|
||||
layout.add_widget(btn_box)
|
||||
|
||||
def save_optimized(instance):
|
||||
new_positions = positions[start_remove:len(positions)-end_remove if end_remove > 0 else None]
|
||||
with open(positions_path, "w") as f:
|
||||
json.dump(new_positions, f, indent=2)
|
||||
label.text = "File optimized and saved!"
|
||||
btn_save.disabled = True
|
||||
btn_cancel.disabled = True
|
||||
def close_and_refresh(dt):
|
||||
popup.dismiss()
|
||||
self.on_pre_enter() # Refresh the screen
|
||||
Clock.schedule_once(close_and_refresh, 1)
|
||||
|
||||
btn_save.bind(on_press=save_optimized)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
|
||||
Clock.schedule_once(process_entries, 0.5)
|
||||
|
||||
def preview_route(self):
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route preview...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Previewing Route",
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 180),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def process_preview(dt):
|
||||
try:
|
||||
import folium
|
||||
import webbrowser
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
html_path = os.path.join(project_folder, "preview.html")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if not positions:
|
||||
label.text = "No positions to preview."
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
# Get coordinates
|
||||
coords = [(pos['latitude'], pos['longitude']) for pos in positions]
|
||||
|
||||
# Center map on first point
|
||||
m = folium.Map(location=coords[0], zoom_start=14)
|
||||
folium.PolyLine(coords, color="blue", weight=4.5, opacity=1).add_to(m)
|
||||
folium.Marker(coords[0], tooltip="Start", icon=folium.Icon(color="green")).add_to(m)
|
||||
folium.Marker(coords[-1], tooltip="End", icon=folium.Icon(color="red")).add_to(m)
|
||||
m.save(html_path)
|
||||
|
||||
# Set the path for the WebView
|
||||
self.preview_html_path = "file://" + os.path.abspath(html_path)
|
||||
label.text = "Preview ready!"
|
||||
progress.value = 100
|
||||
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 1)
|
||||
|
||||
except Exception as e:
|
||||
label.text = f"Error: {e}"
|
||||
progress.value = 100
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 2)
|
||||
|
||||
Clock.schedule_once(process_preview, 0.5)
|
||||
|
||||
|
||||
class HomeScreen(Screen):
|
||||
def on_pre_enter(self):
|
||||
"""Load existing projects/trips when the screen is entered."""
|
||||
self.load_existing_projects()
|
||||
|
||||
def load_existing_projects(self):
|
||||
projects_folder = os.path.join(RESOURCES_FOLDER, "projects")
|
||||
archive_folder = os.path.join(RESOURCES_FOLDER, "trip_archive")
|
||||
if not os.path.exists(projects_folder):
|
||||
os.makedirs(projects_folder)
|
||||
if not os.path.exists(archive_folder):
|
||||
os.makedirs(archive_folder)
|
||||
|
||||
self.ids.projects_list.clear_widgets()
|
||||
|
||||
for project in os.listdir(projects_folder):
|
||||
row = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
spacing=6,
|
||||
padding=(8, 6)
|
||||
)
|
||||
from kivy.graphics import Color, Line, RoundedRectangle
|
||||
with row.canvas.before:
|
||||
Color(0.11, 0.10, 0.15, 1)
|
||||
row.bg_rect = RoundedRectangle(pos=row.pos, size=row.size, radius=[6])
|
||||
Color(0.341, 0.235, 0.980, 1)
|
||||
row.border_line = Line(rounded_rectangle=[row.x, row.y, row.width, row.height, 6], width=1)
|
||||
# Use a closure to bind the correct row instance
|
||||
def make_update_bg_rect(r):
|
||||
def update_bg_rect(instance, value):
|
||||
r.bg_rect.pos = r.pos
|
||||
r.bg_rect.size = r.size
|
||||
r.border_line.rounded_rectangle = [r.x, r.y, r.width, r.height, 6]
|
||||
return update_bg_rect
|
||||
row.bind(pos=make_update_bg_rect(row), size=make_update_bg_rect(row))
|
||||
|
||||
project_label = Label(
|
||||
text=project,
|
||||
size_hint_x=0.64,
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=15,
|
||||
shorten=True,
|
||||
shorten_from='right'
|
||||
)
|
||||
|
||||
edit_button = IconButton(
|
||||
source="resources/images/edit.png",
|
||||
size_hint_x=0.18,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
edit_button.bind(on_press=lambda instance, p=project: self.edit_project(p))
|
||||
|
||||
delete_button = IconButton(
|
||||
source="resources/images/delete.png",
|
||||
size_hint_x=0.18,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
delete_button.bind(on_press=lambda instance, p=project: self.confirm_delete_project(p))
|
||||
|
||||
row.add_widget(project_label)
|
||||
row.add_widget(edit_button)
|
||||
row.add_widget(delete_button)
|
||||
self.ids.projects_list.add_widget(row)
|
||||
|
||||
def open_project(self, project_name):
|
||||
"""Handle opening an existing project/trip."""
|
||||
print(f"Opening project: {project_name}")
|
||||
self.ids.result_label.text = f"Opened project: {project_name}"
|
||||
|
||||
def create_new_project(self):
|
||||
"""Navigate to the GetTripFromServer screen to create a new project/trip."""
|
||||
self.manager.current = "get_trip_from_server"
|
||||
|
||||
def edit_project(self, project_name):
|
||||
# Set the project name on the CreateAnimationScreen before switching
|
||||
create_anim_screen = self.manager.get_screen("create_animation")
|
||||
create_anim_screen.project_name = project_name
|
||||
self.manager.current = "create_animation"
|
||||
|
||||
# delete or archive project
|
||||
def confirm_delete_project(self, project_name):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text=f"Delete project '{project_name}'?\nChoose an option:")
|
||||
btn_delete = Button(text="Delete Completely", background_color=(1, 0, 0, 1))
|
||||
btn_archive = Button(text="Archive Trip", background_color=(0.341, 0.235, 0.980, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
popup = Popup(title="Delete Project", content=layout, size_hint=(None, None), size=(400, 250), auto_dismiss=False)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(btn_delete)
|
||||
layout.add_widget(btn_archive)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
def do_delete(instance):
|
||||
self.delete_project(project_name)
|
||||
popup.dismiss()
|
||||
self.load_existing_projects()
|
||||
|
||||
def do_archive(instance):
|
||||
self.archive_project(project_name)
|
||||
popup.dismiss()
|
||||
self.load_existing_projects()
|
||||
|
||||
btn_delete.bind(on_press=do_delete)
|
||||
btn_archive.bind(on_press=do_archive)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def delete_project(self, project_name):
|
||||
import shutil
|
||||
folder_path = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
if os.path.exists(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
|
||||
def archive_project(self, project_name):
|
||||
import shutil
|
||||
src_file = os.path.join(RESOURCES_FOLDER, "projects", project_name, "positions.json")
|
||||
archive_folder = os.path.join(RESOURCES_FOLDER, "trip_archive")
|
||||
if not os.path.exists(archive_folder):
|
||||
os.makedirs(archive_folder)
|
||||
dst_file = os.path.join(archive_folder, f"{project_name}.json")
|
||||
if os.path.exists(src_file):
|
||||
shutil.copy2(src_file, dst_file)
|
||||
# Optionally, delete the project folder after archiving
|
||||
self.delete_project(project_name)
|
||||
|
||||
class IconButton(ButtonBehavior, Image):
|
||||
pass
|
||||
|
||||
class TraccarApp(App):
|
||||
def build(self):
|
||||
if not os.path.exists(RESOURCES_FOLDER):
|
||||
@@ -824,8 +50,9 @@ class TraccarApp(App):
|
||||
sm.add_widget(SettingsScreen(name="settings"))
|
||||
sm.add_widget(RegisterScreen(name="register"))
|
||||
sm.add_widget(CreateAnimationScreen(name="create_animation"))
|
||||
sm.add_widget(PauseEditScreen(name="pause_edit"))
|
||||
print("Screens added to ScreenManager:", [screen.name for screen in sm.screens])
|
||||
return sm
|
||||
|
||||
if __name__ == "__main__":
|
||||
TraccarApp().run()
|
||||
TraccarApp().run()
|
||||
242
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
242
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Professional Google Earth-Style 3D Video Animation
|
||||
|
||||
## Overview
|
||||
The Professional Google Earth-Style 3D Video Animation feature generates cinematic, high-quality video animations from GPS route data with realistic space entry sequences. This system creates authentic Google Earth-style visuals with professional terrain rendering, atmospheric effects, and spectacular space-to-Earth transitions.
|
||||
|
||||
## Major Visual Enhancements
|
||||
|
||||
### Realistic Google Earth Visuals
|
||||
- **Authentic Earth Sphere Rendering**: Realistic planetary view from space with proper curvature
|
||||
- **Professional Terrain Textures**: Multi-layer terrain with forests, mountains, plains, deserts, and water bodies
|
||||
- **Geographic Feature Simulation**: Coastlines, rivers, and landmasses with fractal-like detail
|
||||
- **Atmospheric Scattering**: Realistic atmospheric effects and color gradients
|
||||
- **Cloud Layer Rendering**: Dynamic cloud formations with proper shadows
|
||||
|
||||
### Enhanced Space Entry Sequence
|
||||
- **Spectacular Space View**: Authentic space background with star fields and Earth sphere
|
||||
- **Realistic Atmospheric Entry**: Progressive transition through atmospheric layers
|
||||
- **Earth's Terminator Line**: Day/night boundary visible at high altitudes
|
||||
- **Professional UI**: Google Earth-style information panels and progress indicators
|
||||
- **Cinematic Descent**: Smooth altitude progression from 50km to route level
|
||||
|
||||
### Advanced Terrain System
|
||||
- **Multi-Octave Terrain Generation**: Realistic landscape using multiple noise layers
|
||||
- **Geographic Coordinate Influence**: Terrain varies based on actual GPS coordinates
|
||||
- **Atmospheric Perspective**: Distance-based color shifts and haze effects
|
||||
- **Cloud Shadow Mapping**: Realistic shadow patterns on terrain
|
||||
- **Enhanced Color Palette**: Professional color schemes for different terrain types
|
||||
|
||||
### Professional UI Elements
|
||||
- **Information Panel**: Speed, bearing, altitude, time, and progress with gradients
|
||||
- **360° Compass**: Full compass with cardinal directions and dynamic needle
|
||||
- **Gradient Progress Bar**: Color-transitioning progress indicator
|
||||
- **Enhanced Typography**: Better text rendering with shadows and effects
|
||||
- **Atmospheric Vignette**: Subtle edge darkening for cinematic feel
|
||||
|
||||
## Technical Specifications
|
||||
- **Resolution**: 1920x1080 (Full HD)
|
||||
- **Frame Rate**: 30 FPS (smooth motion)
|
||||
- **Format**: MP4 video (universal compatibility)
|
||||
- **Compression**: MP4V codec optimized for quality
|
||||
- **Visual Quality**: Professional Google Earth-style rendering
|
||||
- **Space Entry**: 3-second descent from 50km altitude with realistic visuals
|
||||
- **Camera Height**: 1000-3000m (dynamic aerial perspective)
|
||||
- **View Distance**: 3000m ahead (enhanced for aerial views)
|
||||
- **Field of View**: 75° (optimized for aerial perspective)
|
||||
- **Tilt Angle**: 65-73° (dynamic for terrain following)
|
||||
- **Terrain Detail**: Multi-layer realistic terrain with 6+ terrain types
|
||||
- **Color Depth**: Professional color palette with atmospheric effects
|
||||
- **Entry Altitude Range**: 50km → 2km (space to aerial transition)
|
||||
|
||||
## Advanced Animation Features
|
||||
- **Space Entry Sequence**: Spectacular 3-second descent from space to route
|
||||
- **Earth Curvature Rendering**: Realistic planetary curvature at high altitudes
|
||||
- **Atmospheric Transition**: Smooth space-to-atmosphere visual effects
|
||||
- **Enhanced Aerial Perspective**: Optimized 1000-3000m camera height range
|
||||
- **3D Shadow Effects**: Multi-layer shadows for depth
|
||||
- **Elevation Dynamics**: Real-time terrain elevation calculation
|
||||
- **Smooth Interpolation**: Advanced movement interpolation
|
||||
- **Depth Culling**: Performance optimization through view frustum culling
|
||||
- **Route Highlighting**: Progressive route visibility during space descent
|
||||
- **Progressive Rendering**: Back-to-front rendering for proper transparency
|
||||
- **Atmospheric Effects**: Distance-based fog and atmospheric perspective
|
||||
- **Dynamic Lighting**: Simulated lighting based on elevation and distance
|
||||
|
||||
## Required Libraries
|
||||
|
||||
### Core Dependencies
|
||||
- **OpenCV (cv2)**: Video generation and frame composition
|
||||
- **NumPy**: Mathematical operations and array handling
|
||||
- **PIL/Pillow**: Image processing and text rendering
|
||||
- **Requests**: API calls for elevation data (future enhancement)
|
||||
|
||||
### Optional Enhancements
|
||||
- **MoviePy**: Advanced video editing and effects
|
||||
- **Matplotlib**: Additional visualization options
|
||||
- **SciPy**: Mathematical transformations
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Navigate** to the Create Animation screen
|
||||
2. **Select** a project with GPS route data
|
||||
3. **Click** "Generate 3D Video" button
|
||||
4. **Wait** for processing (can take several minutes)
|
||||
5. **View** the generated video in the project folder
|
||||
|
||||
## Enhanced Processing Pipeline
|
||||
|
||||
### 1. Route Analysis & Camera Planning (10-20%)
|
||||
- Advanced GPS data analysis and validation
|
||||
- Dynamic camera path calculation
|
||||
- Elevation profile generation
|
||||
- Viewport optimization for route coverage
|
||||
|
||||
### 2. 3D Scene Setup (20-30%)
|
||||
- Camera position and target calculation
|
||||
- 3D coordinate system establishment
|
||||
- Terrain mesh generation
|
||||
- Lighting and atmosphere setup
|
||||
|
||||
### 3. Enhanced Frame Generation (30-75%)
|
||||
- Dynamic camera positioning for each frame
|
||||
- 3D-to-2D perspective projection
|
||||
- Depth-sorted object rendering
|
||||
- Advanced route visualization with gradients
|
||||
- Multi-layer UI element composition
|
||||
- Atmospheric effect application
|
||||
|
||||
### 4. Video Assembly & Optimization (75-90%)
|
||||
- Frame sequence compilation
|
||||
- Advanced compression with quality optimization
|
||||
- Metadata embedding
|
||||
- Audio track preparation (future enhancement)
|
||||
|
||||
### 5. Post-Processing & Output (90-100%)
|
||||
- Final quality optimization
|
||||
- File system integration
|
||||
- Temporary file cleanup
|
||||
- User notification and result display
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Enhanced Rendering Pipeline
|
||||
```
|
||||
GPS Data → Camera Path Planning → 3D Scene Setup →
|
||||
Dynamic Projection → Depth Sorting → Visual Effects →
|
||||
UI Overlay → Atmospheric Effects → Frame Export
|
||||
```
|
||||
|
||||
### Advanced 3D Mathematics
|
||||
- **Haversine Distance Calculation**: Precise GPS distance computation
|
||||
- **Bearing Calculation**: Accurate directional vectors
|
||||
- **3D Perspective Projection**: Field-of-view based projection
|
||||
- **Matrix Transformations**: Rotation and translation matrices
|
||||
- **Depth Buffer Simulation**: Z-order sorting for realistic rendering
|
||||
|
||||
## File Output
|
||||
|
||||
### Naming Convention
|
||||
```
|
||||
{project_name}_3d_animation_{timestamp}.mp4
|
||||
```
|
||||
|
||||
### Example
|
||||
```
|
||||
MyTrip_3d_animation_20250702_143522.mp4
|
||||
```
|
||||
|
||||
### Location
|
||||
Videos are saved in the project folder:
|
||||
```
|
||||
resources/projects/{project_name}/
|
||||
```
|
||||
|
||||
## Customization Options
|
||||
|
||||
### Future Enhancements
|
||||
- **Real Elevation Data**: Integration with elevation APIs
|
||||
- **Custom Colors**: User-selectable color schemes
|
||||
- **Speed Control**: Variable playback speeds
|
||||
- **Camera Angles**: Multiple perspective options
|
||||
- **Terrain Textures**: Realistic ground textures
|
||||
- **Weather Effects**: Animated weather overlays
|
||||
|
||||
### Performance Optimization
|
||||
- **Multi-threading**: Parallel frame generation
|
||||
- **GPU Acceleration**: OpenGL rendering support
|
||||
- **Compression Options**: Quality vs. file size settings
|
||||
- **Preview Mode**: Lower quality for faster processing
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
- **Insufficient GPS Data**: Minimum 10 points required
|
||||
- **Memory Limitations**: Large routes may require optimization
|
||||
- **Storage Space**: Videos can be 50-200MB depending on route length
|
||||
- **Processing Time**: Can take 5-15 minutes for long routes
|
||||
|
||||
### Troubleshooting
|
||||
- **Reduce Route Size**: Use route optimization before generation
|
||||
- **Free Disk Space**: Ensure adequate storage available
|
||||
- **Close Other Apps**: Free memory for processing
|
||||
- **Check File Permissions**: Ensure write access to project folder
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Frame Generation Pipeline
|
||||
```
|
||||
GPS Point → Coordinate Transform → 3D Projection →
|
||||
Visual Effects → Text Overlay → Frame Export
|
||||
```
|
||||
|
||||
### Video Assembly Pipeline
|
||||
```
|
||||
Frame Sequence → Video Encoder → Compression →
|
||||
Metadata Addition → File Output
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
- **Temporary Files**: Frames stored in temp directory
|
||||
- **Batch Processing**: Processes frames in chunks
|
||||
- **Automatic Cleanup**: Removes temporary files after completion
|
||||
|
||||
## Integration
|
||||
|
||||
### UI Integration
|
||||
- **Progress Bar**: Real-time processing updates
|
||||
- **Status Messages**: Step-by-step progress information
|
||||
- **Error Dialogs**: User-friendly error messages
|
||||
- **Result Notification**: Success/failure feedback
|
||||
|
||||
### File System Integration
|
||||
- **Project Structure**: Maintains existing folder organization
|
||||
- **Automatic Naming**: Prevents file name conflicts
|
||||
- **Folder Opening**: Direct access to output location
|
||||
|
||||
## Space Entry Sequence Details
|
||||
|
||||
### Visual Journey
|
||||
1. **Space View (0-1 seconds)**: Starts from 50km altitude with black space background and Earth curvature
|
||||
2. **Atmospheric Entry (1-2 seconds)**: Gradual transition showing atmospheric layers and blue sky emergence
|
||||
3. **Route Approach (2-3 seconds)**: Descent to 2km altitude with route becoming visible and highlighted
|
||||
4. **Transition Bridge (3-3.5 seconds)**: Smooth bridge frame announcing route start
|
||||
5. **Aerial Following (3.5+ seconds)**: Seamless transition to dynamic camera following at optimal aerial height
|
||||
|
||||
### Technical Implementation
|
||||
- **Altitude Range**: 50,000m → 2,000m → 1,000-3,000m (dynamic)
|
||||
- **Descent Curve**: Cubic ease-out for natural deceleration
|
||||
- **Camera Transition**: Smooth movement from center overview to route start
|
||||
- **Transition Bridge**: Dedicated frame for smooth space-to-route handoff
|
||||
- **Visual Effects**: Earth curvature, atmospheric glow, space-to-sky gradient
|
||||
- **Route Visibility**: Progressive highlighting during descent approach
|
||||
- **Error Handling**: Robust fallback frames ensure generation continues
|
||||
- **Variable Safety**: Protected against undefined position markers
|
||||
|
||||
### Enhanced Aerial Perspective
|
||||
- **Optimal Height Range**: 1000-3000 meters for perfect aerial views
|
||||
- **Dynamic Variation**: Camera height varies smoothly for cinematic effect
|
||||
- **Wide Field of View**: 75° FOV for comprehensive aerial perspective
|
||||
- **Enhanced View Distance**: 3000m ahead for better route anticipation
|
||||
- **Improved Tilt Angle**: 65-73° for optimal aerial viewing angle
|
||||
|
||||
This feature transforms GPS tracking data into professional-quality video animations suitable for sharing, presentations, or personal memories.
|
||||
105
py_scripts/PAUSE_EDIT_IMPROVEMENTS.md
Normal file
105
py_scripts/PAUSE_EDIT_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Pause Edit Screen Improvements
|
||||
|
||||
## Overview
|
||||
The pause edit screen has been completely redesigned for better mobile usability and enhanced user experience.
|
||||
|
||||
## New Features Implemented
|
||||
|
||||
### 1. Loading Popup
|
||||
- **Purpose**: Indicates that the app is loading pause data and location suggestions
|
||||
- **Implementation**: Shows a progress bar animation while loading data in background
|
||||
- **User Experience**: Prevents the app from appearing unresponsive during startup
|
||||
|
||||
### 2. Carousel Navigation
|
||||
- **When**: Automatically activated when there are more than 2 pauses
|
||||
- **Features**:
|
||||
- Swipe navigation between pauses
|
||||
- Loop mode for continuous navigation
|
||||
- Visual indicators showing current pause (e.g., "Pause 2 of 5")
|
||||
- **Fallback**: Simple scroll view for 1-2 pauses
|
||||
|
||||
### 3. Vertical Photo Scrolling
|
||||
- **Implementation**: Each pause has a vertical scroll area for photos
|
||||
- **Features**:
|
||||
- Thumbnail image previews (55px width)
|
||||
- Traditional vertical list layout for better mobile usability
|
||||
- Improved photo item styling with borders and file information
|
||||
- View and delete buttons for each photo
|
||||
- File size and format information display
|
||||
|
||||
### 4. Enhanced Location Suggestions
|
||||
- **Caching**: Location suggestions are pre-loaded and cached during startup
|
||||
- **Multi-strategy**: Uses multiple approaches to find meaningful location names
|
||||
- **Fallback**: Graceful degradation to coordinates if no location found
|
||||
|
||||
### 5. Mobile-Optimized UI
|
||||
- **Responsive Design**: Better layout for phone screens
|
||||
- **Touch-Friendly**: Larger buttons and touch targets
|
||||
- **Visual Feedback**: Better borders, colors, and spacing
|
||||
|
||||
### 6. Delete Pause Functionality
|
||||
- **Purpose**: Allow users to completely remove unwanted pauses
|
||||
- **Implementation**: Delete button next to save button for each pause
|
||||
- **Features**:
|
||||
- Confirmation dialog before deletion
|
||||
- Removes pause from locations list
|
||||
- Deletes all associated photos and folder
|
||||
- Automatically reorganizes remaining pause folders
|
||||
- Updates pause numbering sequence
|
||||
|
||||
## Updated Features (Latest Changes)
|
||||
|
||||
### Photo Scrolling Direction Changed
|
||||
- **From**: Horizontal scrolling with large previews
|
||||
- **To**: Vertical scrolling with compact thumbnail layout
|
||||
- **Benefit**: Better mobile usability and more familiar interface
|
||||
|
||||
### Delete Pause Button Added
|
||||
- **Location**: Next to "Save Pause Info" button
|
||||
- **Functionality**: Complete pause removal with confirmation
|
||||
- **Safety**: Confirmation dialog prevents accidental deletion
|
||||
- **Clean-up**: Automatic folder reorganization and numbering
|
||||
|
||||
## File Structure
|
||||
- `pause_edit_screen_improved.py`: New, clean implementation with all features
|
||||
- `pause_edit_screen_legacy.py`: Original file (renamed for backup)
|
||||
- `main.py`: Updated to use the improved version
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Loading Process
|
||||
1. Show loading popup immediately
|
||||
2. Load pause data in background thread
|
||||
3. Pre-process location suggestions
|
||||
4. Build UI on main thread
|
||||
5. Dismiss loading popup
|
||||
|
||||
### Carousel Logic
|
||||
```python
|
||||
if len(pauses) > 2:
|
||||
use_carousel_layout()
|
||||
else:
|
||||
use_simple_scroll_layout()
|
||||
```
|
||||
|
||||
### Photo Scrolling
|
||||
- Vertical ScrollView with `do_scroll_y=True, do_scroll_x=False`
|
||||
- Fixed-height photo items (60px)
|
||||
- Dynamic content height based on number of photos
|
||||
- Thumbnail layout with file information display
|
||||
|
||||
## Benefits
|
||||
1. **Improved Performance**: Background loading prevents UI freezing
|
||||
2. **Better Navigation**: Carousel makes it easy to navigate many pauses
|
||||
3. **Enhanced Photo Management**: Vertical scrolling provides familiar mobile interface
|
||||
4. **Professional Feel**: Loading indicators and smooth animations
|
||||
5. **Mobile-First**: Optimized for touch interaction
|
||||
6. **Complete Control**: Can delete unwanted pauses with safety confirmation
|
||||
7. **Better Organization**: Automatic reorganization maintains clean folder structure
|
||||
|
||||
## Usage
|
||||
The improved screen is now the default pause edit screen in the application. Users will automatically see:
|
||||
- Loading popup on screen entry
|
||||
- Carousel navigation for 3+ pauses
|
||||
- Horizontal photo scrolling in each pause
|
||||
- Cached location suggestions for faster loading
|
||||
2
py_scripts/__init__.py
Normal file
2
py_scripts/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# py_scripts package
|
||||
# Contains utility scripts for the Traccar Animation application
|
||||
1232
py_scripts/advanced_3d_generator.py
Normal file
1232
py_scripts/advanced_3d_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
333
py_scripts/blender_animator.py
Normal file
333
py_scripts/blender_animator.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import json
|
||||
import numpy as np
|
||||
import os
|
||||
from datetime import datetime
|
||||
import math
|
||||
|
||||
# Blender dependencies with fallback handling
|
||||
try:
|
||||
import bpy
|
||||
import bmesh
|
||||
from mathutils import Vector, Euler
|
||||
BLENDER_AVAILABLE = True
|
||||
except ImportError:
|
||||
BLENDER_AVAILABLE = False
|
||||
print("Warning: Blender (bpy) not available. This module requires Blender to be installed with Python API access.")
|
||||
|
||||
class BlenderGPSAnimator:
|
||||
"""
|
||||
Advanced GPS track animation using Blender for high-quality 3D rendering
|
||||
"""
|
||||
|
||||
def __init__(self, output_folder):
|
||||
self.output_folder = output_folder
|
||||
if BLENDER_AVAILABLE:
|
||||
self.setup_blender_scene()
|
||||
else:
|
||||
# Don't raise error here, let the caller handle the check
|
||||
pass
|
||||
|
||||
def check_dependencies(self):
|
||||
"""Check if Blender dependencies are available"""
|
||||
if not BLENDER_AVAILABLE:
|
||||
raise ImportError("Blender (bpy) is not available. Please install Blender with Python API access.")
|
||||
return True
|
||||
|
||||
def setup_blender_scene(self):
|
||||
"""Setup Blender scene for GPS animation"""
|
||||
# Clear existing mesh objects
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
# Add camera
|
||||
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||
self.camera = bpy.context.object
|
||||
|
||||
# Add sun light
|
||||
bpy.ops.object.light_add(type='SUN', location=(0, 0, 20))
|
||||
light = bpy.context.object
|
||||
light.data.energy = 5
|
||||
|
||||
# Setup world environment
|
||||
world = bpy.context.scene.world
|
||||
world.use_nodes = True
|
||||
env_texture = world.node_tree.nodes.new('ShaderNodeTexEnvironment')
|
||||
world.node_tree.links.new(env_texture.outputs[0], world.node_tree.nodes['Background'].inputs[0])
|
||||
|
||||
# Set render settings
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.render.resolution_x = 1920
|
||||
scene.render.resolution_y = 1080
|
||||
scene.render.fps = 30
|
||||
scene.cycles.samples = 64
|
||||
|
||||
def load_gps_data(self, positions_file):
|
||||
"""Load GPS data from JSON file"""
|
||||
with open(positions_file, 'r') as f:
|
||||
positions = json.load(f)
|
||||
|
||||
# Convert to numpy array for easier processing
|
||||
coords = []
|
||||
times = []
|
||||
speeds = []
|
||||
|
||||
for pos in positions:
|
||||
coords.append([pos['longitude'], pos['latitude'], pos.get('altitude', 0)])
|
||||
times.append(pos['fixTime'])
|
||||
speeds.append(pos.get('speed', 0) * 1.852) # Convert to km/h
|
||||
|
||||
return np.array(coords), times, speeds
|
||||
|
||||
def create_terrain_mesh(self, coords):
|
||||
"""Create a simple terrain mesh based on GPS bounds"""
|
||||
# Calculate bounds
|
||||
min_lon, min_lat = coords[:, :2].min(axis=0)
|
||||
max_lon, max_lat = coords[:, :2].max(axis=0)
|
||||
|
||||
# Expand bounds slightly
|
||||
padding = 0.001
|
||||
min_lon -= padding
|
||||
min_lat -= padding
|
||||
max_lon += padding
|
||||
max_lat += padding
|
||||
|
||||
# Create terrain mesh
|
||||
bpy.ops.mesh.primitive_plane_add(size=2, location=(0, 0, 0))
|
||||
terrain = bpy.context.object
|
||||
terrain.name = "Terrain"
|
||||
|
||||
# Scale terrain to match GPS bounds
|
||||
lon_range = max_lon - min_lon
|
||||
lat_range = max_lat - min_lat
|
||||
scale_factor = max(lon_range, lat_range) * 100000 # Convert to reasonable scale
|
||||
|
||||
terrain.scale = (scale_factor, scale_factor, 1)
|
||||
|
||||
# Apply material
|
||||
mat = bpy.data.materials.new(name="TerrainMaterial")
|
||||
mat.use_nodes = True
|
||||
mat.node_tree.nodes.clear()
|
||||
|
||||
# Add principled BSDF
|
||||
bsdf = mat.node_tree.nodes.new(type='ShaderNodeBsdfPrincipled')
|
||||
bsdf.inputs['Base Color'].default_value = (0.2, 0.5, 0.2, 1.0) # Green
|
||||
bsdf.inputs['Roughness'].default_value = 0.8
|
||||
|
||||
material_output = mat.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
|
||||
mat.node_tree.links.new(bsdf.outputs['BSDF'], material_output.inputs['Surface'])
|
||||
|
||||
terrain.data.materials.append(mat)
|
||||
|
||||
return terrain
|
||||
|
||||
def create_gps_track_mesh(self, coords):
|
||||
"""Create a 3D mesh for the GPS track"""
|
||||
# Normalize coordinates to Blender scale
|
||||
coords_normalized = self.normalize_coordinates(coords)
|
||||
|
||||
# Create curve from GPS points
|
||||
curve_data = bpy.data.curves.new('GPSTrack', type='CURVE')
|
||||
curve_data.dimensions = '3D'
|
||||
curve_data.bevel_depth = 0.02
|
||||
curve_data.bevel_resolution = 4
|
||||
|
||||
# Create spline
|
||||
spline = curve_data.splines.new('BEZIER')
|
||||
spline.bezier_points.add(len(coords_normalized) - 1)
|
||||
|
||||
for i, coord in enumerate(coords_normalized):
|
||||
point = spline.bezier_points[i]
|
||||
point.co = coord
|
||||
point.handle_left_type = 'AUTO'
|
||||
point.handle_right_type = 'AUTO'
|
||||
|
||||
# Create object from curve
|
||||
track_obj = bpy.data.objects.new('GPSTrack', curve_data)
|
||||
bpy.context.collection.objects.link(track_obj)
|
||||
|
||||
# Apply material
|
||||
mat = bpy.data.materials.new(name="TrackMaterial")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||
bsdf.inputs['Base Color'].default_value = (1.0, 0.0, 0.0, 1.0) # Red
|
||||
bsdf.inputs['Emission'].default_value = (1.0, 0.2, 0.2, 1.0)
|
||||
bsdf.inputs['Emission Strength'].default_value = 2.0
|
||||
|
||||
track_obj.data.materials.append(mat)
|
||||
|
||||
return track_obj
|
||||
|
||||
def create_vehicle_model(self):
|
||||
"""Create a simple vehicle model"""
|
||||
# Create a simple car shape using cubes
|
||||
bpy.ops.mesh.primitive_cube_add(size=0.1, location=(0, 0, 0.05))
|
||||
vehicle = bpy.context.object
|
||||
vehicle.name = "Vehicle"
|
||||
vehicle.scale = (2, 1, 0.5)
|
||||
|
||||
# Apply material
|
||||
mat = bpy.data.materials.new(name="VehicleMaterial")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||
bsdf.inputs['Base Color'].default_value = (0.0, 0.0, 1.0, 1.0) # Blue
|
||||
bsdf.inputs['Metallic'].default_value = 0.5
|
||||
bsdf.inputs['Roughness'].default_value = 0.2
|
||||
|
||||
vehicle.data.materials.append(mat)
|
||||
|
||||
return vehicle
|
||||
|
||||
def normalize_coordinates(self, coords):
|
||||
"""Normalize GPS coordinates to Blender scale"""
|
||||
# Center coordinates
|
||||
center = coords.mean(axis=0)
|
||||
coords_centered = coords - center
|
||||
|
||||
# Scale to reasonable size for Blender
|
||||
scale_factor = 100
|
||||
coords_scaled = coords_centered * scale_factor
|
||||
|
||||
# Convert to Vector objects
|
||||
return [Vector((x, y, z)) for x, y, z in coords_scaled]
|
||||
|
||||
def animate_vehicle(self, vehicle, coords, times, speeds):
|
||||
"""Create animation keyframes for vehicle movement"""
|
||||
coords_normalized = self.normalize_coordinates(coords)
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = len(coords_normalized) * 2 # 2 frames per GPS point
|
||||
|
||||
for i, (coord, speed) in enumerate(zip(coords_normalized, speeds)):
|
||||
frame = i * 2 + 1
|
||||
|
||||
# Set location
|
||||
vehicle.location = coord
|
||||
vehicle.keyframe_insert(data_path="location", frame=frame)
|
||||
|
||||
# Calculate rotation based on direction
|
||||
if i < len(coords_normalized) - 1:
|
||||
next_coord = coords_normalized[i + 1]
|
||||
direction = next_coord - coord
|
||||
if direction.length > 0:
|
||||
direction.normalize()
|
||||
# Calculate rotation angle
|
||||
angle = math.atan2(direction.y, direction.x)
|
||||
vehicle.rotation_euler = Euler((0, 0, angle), 'XYZ')
|
||||
vehicle.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
# Set interpolation mode
|
||||
if vehicle.animation_data:
|
||||
for fcurve in vehicle.animation_data.action.fcurves:
|
||||
for keyframe in fcurve.keyframe_points:
|
||||
keyframe.interpolation = 'BEZIER'
|
||||
|
||||
def animate_camera(self, coords):
|
||||
"""Create smooth camera animation following the vehicle"""
|
||||
coords_normalized = self.normalize_coordinates(coords)
|
||||
|
||||
# Create camera path
|
||||
for i, coord in enumerate(coords_normalized):
|
||||
frame = i * 2 + 1
|
||||
|
||||
# Position camera above and behind the vehicle
|
||||
offset = Vector((0, -2, 3))
|
||||
cam_location = coord + offset
|
||||
|
||||
self.camera.location = cam_location
|
||||
self.camera.keyframe_insert(data_path="location", frame=frame)
|
||||
|
||||
# Look at the vehicle
|
||||
direction = coord - cam_location
|
||||
if direction.length > 0:
|
||||
rot_quat = direction.to_track_quat('-Z', 'Y')
|
||||
self.camera.rotation_euler = rot_quat.to_euler()
|
||||
self.camera.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
def add_particles_effects(self, vehicle):
|
||||
"""Add particle effects for enhanced visuals"""
|
||||
# Add dust particles
|
||||
bpy.context.view_layer.objects.active = vehicle
|
||||
bpy.ops.object.modifier_add(type='PARTICLE_SYSTEM')
|
||||
|
||||
particles = vehicle.modifiers["ParticleSystem"].particle_system
|
||||
particles.settings.count = 100
|
||||
particles.settings.lifetime = 30
|
||||
particles.settings.emit_from = 'FACE'
|
||||
particles.settings.physics_type = 'NEWTON'
|
||||
particles.settings.effector_weights.gravity = 0.1
|
||||
|
||||
# Set material for particles
|
||||
particles.settings.material = 1
|
||||
|
||||
def render_animation(self, output_path, progress_callback=None):
|
||||
"""Render the animation to video"""
|
||||
scene = bpy.context.scene
|
||||
|
||||
# Set output settings
|
||||
scene.render.filepath = output_path
|
||||
scene.render.image_settings.file_format = 'FFMPEG'
|
||||
scene.render.ffmpeg.format = 'MPEG4'
|
||||
scene.render.ffmpeg.codec = 'H264'
|
||||
|
||||
# Render animation
|
||||
total_frames = scene.frame_end - scene.frame_start + 1
|
||||
|
||||
for frame in range(scene.frame_start, scene.frame_end + 1):
|
||||
scene.frame_set(frame)
|
||||
|
||||
# Render frame
|
||||
frame_path = f"{output_path}_{frame:04d}.png"
|
||||
scene.render.filepath = frame_path
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
# Update progress
|
||||
if progress_callback:
|
||||
progress = ((frame - scene.frame_start) / total_frames) * 100
|
||||
progress_callback(progress, f"Rendering frame {frame}/{scene.frame_end}")
|
||||
|
||||
def create_gps_animation(self, positions_file, output_path, progress_callback=None):
|
||||
"""Main method to create GPS animation in Blender"""
|
||||
try:
|
||||
# Load GPS data
|
||||
coords, times, speeds = self.load_gps_data(positions_file)
|
||||
|
||||
# Create scene elements
|
||||
terrain = self.create_terrain_mesh(coords)
|
||||
track = self.create_gps_track_mesh(coords)
|
||||
vehicle = self.create_vehicle_model()
|
||||
|
||||
# Create animations
|
||||
self.animate_vehicle(vehicle, coords, times, speeds)
|
||||
self.animate_camera(coords)
|
||||
|
||||
# Add effects
|
||||
self.add_particles_effects(vehicle)
|
||||
|
||||
# Render animation
|
||||
self.render_animation(output_path, progress_callback)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating Blender animation: {e}")
|
||||
if progress_callback:
|
||||
progress_callback(-1, f"Error: {e}")
|
||||
return False
|
||||
|
||||
def generate_blender_animation(positions_file, output_folder, progress_callback=None):
|
||||
"""
|
||||
Convenience function to generate Blender animation
|
||||
"""
|
||||
animator = BlenderGPSAnimator(output_folder)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = os.path.join(output_folder, f"blender_animation_{timestamp}")
|
||||
|
||||
success = animator.create_gps_animation(positions_file, output_path, progress_callback)
|
||||
|
||||
return f"{output_path}.mp4" if success else None
|
||||
|
||||
# Note: This script should be run from within Blender's Python environment
|
||||
# or with Blender as a Python module (bpy)
|
||||
492
py_scripts/utils.py
Normal file
492
py_scripts/utils.py
Normal file
@@ -0,0 +1,492 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
import math
|
||||
import datetime
|
||||
RESOURCES_FOLDER = "resources"
|
||||
CREDENTIALS_FILE = os.path.join(RESOURCES_FOLDER, "credentials.enc")
|
||||
KEY_FILE = os.path.join(RESOURCES_FOLDER, "key.key")
|
||||
SERVER_SETTINGS_FILE = os.path.join(RESOURCES_FOLDER, "server_settings.enc")
|
||||
|
||||
# --- Encryption Utilities ---
|
||||
|
||||
def generate_key():
|
||||
"""Generate and save a key for encryption."""
|
||||
if not os.path.exists(KEY_FILE):
|
||||
key = Fernet.generate_key()
|
||||
with open(KEY_FILE, "wb") as key_file:
|
||||
key_file.write(key)
|
||||
|
||||
def load_key():
|
||||
"""Load the encryption key."""
|
||||
with open(KEY_FILE, "rb") as key_file:
|
||||
return key_file.read()
|
||||
|
||||
def encrypt_data(data):
|
||||
"""Encrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.encrypt(data.encode())
|
||||
|
||||
def decrypt_data(data):
|
||||
"""Decrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.decrypt(data).decode()
|
||||
|
||||
# --- Server Settings ---
|
||||
def check_server_settings():
|
||||
"""Load and decrypt server settings from file."""
|
||||
if not os.path.exists(SERVER_SETTINGS_FILE):
|
||||
return None
|
||||
try:
|
||||
with open(SERVER_SETTINGS_FILE, "rb") as file:
|
||||
encrypted_data = file.read()
|
||||
decrypted_data = decrypt_data(encrypted_data)
|
||||
settings = json.loads(decrypted_data)
|
||||
return settings
|
||||
except Exception as e:
|
||||
print(f"Failed to load server settings: {e}")
|
||||
return None
|
||||
|
||||
def save_server_settings(settings_data):
|
||||
"""Encrypt and save server settings."""
|
||||
encrypted_data = encrypt_data(json.dumps(settings_data))
|
||||
with open(SERVER_SETTINGS_FILE, "wb") as file:
|
||||
file.write(encrypted_data)
|
||||
|
||||
# --- Traccar Server Connection ---
|
||||
def test_connection(server_url, username=None, password=None, token=None):
|
||||
"""
|
||||
Test the connection with the Traccar server.
|
||||
Returns: dict with 'status' (bool) and 'message' (str)
|
||||
"""
|
||||
if not server_url:
|
||||
return {"status": False, "message": "Please provide the server URL."}
|
||||
if not token and (not username or not password):
|
||||
return {"status": False, "message": "Please provide either a token or username and password."}
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
auth = None if token else (username, password)
|
||||
response = requests.get(f"{server_url}/api/server", headers=headers, auth=auth, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return {"status": True, "message": "Connection successful! Server is reachable."}
|
||||
else:
|
||||
return {"status": False, "message": f"Error: {response.status_code} - {response.reason}"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"status": False, "message": "Connection timed out. Please try again."}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"status": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
# --- Device Fetching ---
|
||||
def get_devices_from_server():
|
||||
"""Retrieve a mapping of device names to IDs from the Traccar server."""
|
||||
settings = check_server_settings()
|
||||
if not settings:
|
||||
return None
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
if not server_url or not token:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
response = requests.get(f"{server_url}/api/devices", headers=headers)
|
||||
if response.status_code == 200:
|
||||
devices = response.json()
|
||||
return {device.get("name", "Unnamed Device"): device.get("id", "Unknown ID") for device in devices}
|
||||
else:
|
||||
print(f"Error: {response.status_code} - {response.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error retrieving devices: {str(e)}")
|
||||
return None
|
||||
|
||||
# --- Route Saving ---
|
||||
def save_route_to_file(route_name, positions, base_folder="resources/projects"):
|
||||
"""
|
||||
Save the given positions as a route in resources/projects/<route_name>/positions.json.
|
||||
Returns (success, message, file_path)
|
||||
"""
|
||||
if not route_name:
|
||||
return False, "Please enter a route name.", None
|
||||
if not positions:
|
||||
return False, "No positions to save.", None
|
||||
|
||||
folder_path = os.path.join(base_folder, route_name)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
file_path = os.path.join(folder_path, "positions.json")
|
||||
try:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(positions, f, indent=2)
|
||||
return True, f"Route '{route_name}' saved!", file_path
|
||||
except Exception as e:
|
||||
return False, f"Failed to save route: {str(e)}", None
|
||||
|
||||
def fetch_positions(server_url, token, device_id, from_time, to_time):
|
||||
"""
|
||||
Fetch positions from the Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
url = f"{server_url}/api/reports/route"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
params = {
|
||||
"deviceId": device_id,
|
||||
"from": from_time,
|
||||
"to": to_time
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json(), None
|
||||
elif response.status_code == 400:
|
||||
return None, "Bad Request: Please check the request payload and token."
|
||||
else:
|
||||
return None, f"Failed: {response.status_code} - {response.reason}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
return None, f"Error fetching positions: {str(e)}"
|
||||
|
||||
def fetch_positions_for_selected_day(settings, device_mapping, device_name, start_date, end_date, start_hour, end_hour):
|
||||
"""
|
||||
Fetch positions for the selected day/device using Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
if not settings:
|
||||
return [], "Server settings not found."
|
||||
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
device_id = device_mapping.get(device_name)
|
||||
if not device_id:
|
||||
return [], "Device ID not found."
|
||||
|
||||
from_time = f"{start_date}T{start_hour}:00:00Z"
|
||||
to_time = f"{end_date}T{end_hour}:59:59Z"
|
||||
|
||||
positions, error = fetch_positions(server_url, token, device_id, from_time, to_time)
|
||||
if error:
|
||||
return [], error
|
||||
return positions, None
|
||||
|
||||
def html_to_image(html_path, img_path, width=1280, height=720, delay=2, driver_path='/usr/bin/chromedriver'):
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from PIL import Image
|
||||
import time
|
||||
import os
|
||||
|
||||
selenium_height = int(height * 1.2) # 10% taller for compensation
|
||||
|
||||
chrome_options = Options()
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument(f"--window-size={width},{selenium_height}")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
service = Service(driver_path)
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
try:
|
||||
driver.set_window_size(width, selenium_height)
|
||||
driver.get("file://" + os.path.abspath(html_path))
|
||||
time.sleep(delay)
|
||||
tmp_img = img_path + ".tmp.png"
|
||||
driver.save_screenshot(tmp_img)
|
||||
driver.quit()
|
||||
|
||||
img = Image.open(tmp_img)
|
||||
img = img.crop((0, 0, width, height)) # Crop to original map size
|
||||
img.save(img_path)
|
||||
os.remove(tmp_img)
|
||||
print(f"Image saved to: {img_path} ({width}x{height})")
|
||||
except Exception as e:
|
||||
print(f"Error converting HTML to image: {e}")
|
||||
driver.quit()
|
||||
|
||||
def process_preview_util(
|
||||
project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
preview_image_widget,
|
||||
set_preview_image_path,
|
||||
Clock,
|
||||
width=800,
|
||||
height=600
|
||||
):
|
||||
import folium
|
||||
import os
|
||||
import json
|
||||
# Import html_to_image function from within the same module
|
||||
# (it's defined later in this file)
|
||||
|
||||
try:
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
html_path = os.path.join(project_folder, "preview.html")
|
||||
img_path = os.path.join(project_folder, "preview.png")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if not positions:
|
||||
label.text = "No positions to preview."
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
coords = [(pos['latitude'], pos['longitude']) for pos in positions]
|
||||
width, height = 1280, 720 # 16:9 HD
|
||||
|
||||
m = folium.Map(
|
||||
location=coords[0],
|
||||
width=width,
|
||||
height=height,
|
||||
control_scale=True
|
||||
)
|
||||
folium.PolyLine(coords, color="blue", weight=4.5, opacity=1).add_to(m)
|
||||
folium.Marker(coords[0], tooltip="Start", icon=folium.Icon(color="green")).add_to(m)
|
||||
folium.Marker(coords[-1], tooltip="End", icon=folium.Icon(color="red")).add_to(m)
|
||||
|
||||
# --- Add pause markers if pauses.json exists ---
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
if os.path.exists(pauses_path):
|
||||
with open(pauses_path, "r") as pf:
|
||||
pauses = json.load(pf)
|
||||
for pause in pauses:
|
||||
lat = pause["location"]["latitude"]
|
||||
lon = pause["location"]["longitude"]
|
||||
duration = pause["duration_seconds"]
|
||||
start = pause["start_time"]
|
||||
end = pause["end_time"]
|
||||
folium.Marker(
|
||||
[lat, lon],
|
||||
tooltip=f"Pause: {duration//60} min {duration%60} sec",
|
||||
popup=f"Pause from {start} to {end} ({duration//60} min {duration%60} sec)",
|
||||
icon=folium.Icon(color="orange", icon="pause", prefix="fa")
|
||||
).add_to(m)
|
||||
|
||||
m.fit_bounds(coords, padding=(80, 80))
|
||||
m.get_root().html.add_child(folium.Element(f"""
|
||||
<style>
|
||||
html, body {{
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}}
|
||||
#{m.get_name()} {{
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: 0; right: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}}
|
||||
</style>
|
||||
"""))
|
||||
m.save(html_path)
|
||||
|
||||
html_to_image(html_path, img_path, width=width, height=height)
|
||||
|
||||
set_preview_image_path(img_path)
|
||||
preview_image_widget.reload()
|
||||
label.text = "Preview ready!"
|
||||
progress.value = 100
|
||||
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 1)
|
||||
|
||||
except Exception as e:
|
||||
label.text = f"Error: {e}"
|
||||
progress.value = 100
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 2)
|
||||
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
# Returns distance in meters between two lat/lon points
|
||||
R = 6371000 # Earth radius in meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
def optimize_route_entries_util(
|
||||
project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
Clock,
|
||||
on_save=None
|
||||
):
|
||||
def process_entries(dt):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
# Detect duplicate positions at the start
|
||||
start_remove = 0
|
||||
if positions:
|
||||
first = positions[0]
|
||||
for pos in positions:
|
||||
if pos['latitude'] == first['latitude'] and pos['longitude'] == first['longitude']:
|
||||
start_remove += 1
|
||||
else:
|
||||
break
|
||||
if start_remove > 0:
|
||||
start_remove -= 1
|
||||
|
||||
# Detect duplicate positions at the end
|
||||
end_remove = 0
|
||||
if positions:
|
||||
last = positions[-1]
|
||||
for pos in reversed(positions):
|
||||
if pos['latitude'] == last['latitude'] and pos['longitude'] == last['longitude']:
|
||||
end_remove += 1
|
||||
else:
|
||||
break
|
||||
if end_remove > 0:
|
||||
end_remove -= 1
|
||||
|
||||
# Shorten the positions list
|
||||
new_positions = positions[start_remove:len(positions)-end_remove if end_remove > 0 else None]
|
||||
|
||||
# --- PAUSE DETECTION ---
|
||||
pauses = []
|
||||
if new_positions:
|
||||
pause_start = None
|
||||
pause_end = None
|
||||
pause_location = None
|
||||
for i in range(1, len(new_positions)):
|
||||
prev = new_positions[i-1]
|
||||
curr = new_positions[i]
|
||||
# Check if stopped (same location)
|
||||
if curr['latitude'] == prev['latitude'] and curr['longitude'] == prev['longitude']:
|
||||
if pause_start is None:
|
||||
pause_start = prev['deviceTime']
|
||||
pause_location = (prev['latitude'], prev['longitude'])
|
||||
pause_end = curr['deviceTime']
|
||||
else:
|
||||
if pause_start and pause_end:
|
||||
# Calculate pause duration
|
||||
t1 = datetime.datetime.fromisoformat(pause_start.replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause_end.replace('Z', '+00:00'))
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if duration >= 120:
|
||||
pauses.append({
|
||||
"start_time": pause_start,
|
||||
"end_time": pause_end,
|
||||
"duration_seconds": int(duration),
|
||||
"location": {"latitude": pause_location[0], "longitude": pause_location[1]}
|
||||
})
|
||||
pause_start = None
|
||||
pause_end = None
|
||||
pause_location = None
|
||||
# Check for pause at the end
|
||||
if pause_start and pause_end:
|
||||
t1 = datetime.datetime.fromisoformat(pause_start.replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause_end.replace('Z', '+00:00'))
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if duration >= 120:
|
||||
pauses.append({
|
||||
"start_time": pause_start,
|
||||
"end_time": pause_end,
|
||||
"duration_seconds": int(duration),
|
||||
"location": {"latitude": pause_location[0], "longitude": pause_location[1]}
|
||||
})
|
||||
|
||||
# --- FILTER PAUSES ---
|
||||
# 1. Remove pauses near start/end
|
||||
filtered_pauses = []
|
||||
if new_positions and pauses:
|
||||
start_lat, start_lon = new_positions[0]['latitude'], new_positions[0]['longitude']
|
||||
end_lat, end_lon = new_positions[-1]['latitude'], new_positions[-1]['longitude']
|
||||
for pause in pauses:
|
||||
plat = pause["location"]["latitude"]
|
||||
plon = pause["location"]["longitude"]
|
||||
dist_start = haversine(start_lat, start_lon, plat, plon)
|
||||
dist_end = haversine(end_lat, end_lon, plat, plon)
|
||||
if dist_start < 50 or dist_end < 50:
|
||||
continue # Skip pauses near start or end
|
||||
filtered_pauses.append(pause)
|
||||
else:
|
||||
filtered_pauses = pauses
|
||||
|
||||
# 2. Merge pauses close in time and space
|
||||
merged_pauses = []
|
||||
filtered_pauses.sort(key=lambda p: p["start_time"])
|
||||
for pause in filtered_pauses:
|
||||
if not merged_pauses:
|
||||
merged_pauses.append(pause)
|
||||
else:
|
||||
last = merged_pauses[-1]
|
||||
# Time difference in seconds
|
||||
t1 = datetime.datetime.fromisoformat(last["end_time"].replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause["start_time"].replace('Z', '+00:00'))
|
||||
time_diff = (t2 - t1).total_seconds()
|
||||
# Distance in meters
|
||||
last_lat = last["location"]["latitude"]
|
||||
last_lon = last["location"]["longitude"]
|
||||
plat = pause["location"]["latitude"]
|
||||
plon = pause["location"]["longitude"]
|
||||
dist = haversine(last_lat, last_lon, plat, plon)
|
||||
if time_diff < 300 and dist < 50:
|
||||
# Merge: extend last pause's end_time and duration
|
||||
last["end_time"] = pause["end_time"]
|
||||
last["duration_seconds"] += pause["duration_seconds"]
|
||||
else:
|
||||
merged_pauses.append(pause)
|
||||
pauses = merged_pauses
|
||||
|
||||
progress.value = 100
|
||||
label.text = (
|
||||
f"Entries removable at start: {start_remove}\n"
|
||||
f"Entries removable at end: {end_remove}\n"
|
||||
f"Detected pauses: {len(pauses)}"
|
||||
)
|
||||
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
|
||||
btn_save = Button(text="Save optimized file", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
btn_box = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=44)
|
||||
btn_box.add_widget(btn_save)
|
||||
btn_box.add_widget(btn_cancel)
|
||||
popup.content.add_widget(btn_box)
|
||||
|
||||
def save_optimized(instance):
|
||||
with open(positions_path, "w") as f:
|
||||
json.dump(new_positions, f, indent=2)
|
||||
with open(pauses_path, "w") as f:
|
||||
json.dump(pauses, f, indent=2)
|
||||
label.text = "File optimized and pauses saved!"
|
||||
btn_save.disabled = True
|
||||
btn_cancel.disabled = True
|
||||
def close_and_refresh(dt):
|
||||
popup.dismiss()
|
||||
if on_save:
|
||||
on_save()
|
||||
Clock.schedule_once(close_and_refresh, 1)
|
||||
|
||||
btn_save.bind(on_press=save_optimized)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
|
||||
Clock.schedule_once(process_entries, 0.5)
|
||||
15
py_scripts/webview.py
Normal file
15
py_scripts/webview.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
chrome_options = Options()
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--window-size=800,600")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
service = Service('/usr/bin/chromedriver')
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
driver.get("https://www.google.com")
|
||||
driver.save_screenshot("/home/pi/Desktop/test.png")
|
||||
driver.quit()
|
||||
@@ -1,5 +0,0 @@
|
||||
kivy
|
||||
cryptography
|
||||
kiwy-garden
|
||||
folium
|
||||
webview
|
||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
kivy
|
||||
cryptography
|
||||
kivy-garden
|
||||
folium
|
||||
selenium
|
||||
pillow
|
||||
geopy
|
||||
opencv-python
|
||||
requests
|
||||
numpy
|
||||
matplotlib
|
||||
scipy
|
||||
imageio
|
||||
ffmpeg-python
|
||||
pydeck
|
||||
plotly
|
||||
dash
|
||||
pandas
|
||||
geopandas
|
||||
bpy
|
||||
@@ -1 +0,0 @@
|
||||
gAAAAABoQpPZACNsq8I-7nR2jxmpdGK7_yMb0uOaFZp4j21eRvFiCZ0VnPUx8t331lh-TDHUBEfM_sLVdo0ZmZY9DjjVVDr4T3uJx8HwzZLVZmj9jiy8ICtau6OaPvk8Q_RYTAbMXqi7
|
||||
@@ -1 +0,0 @@
|
||||
wetp_PNG9CC5432-W9H3rUbaqIurwldZxHlOgori5kY=
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
gAAAAABoPu5x585IL9U8GSHw4j-KQTpHJixfiwEHQf9KHR25D2fFcYDz6HrJzFP4U3iFxcV9dQQ1VhgDfDPO_nVDafVjMz9kiJdbp1KtiSyB8odqNmq1v6ZfLr_YXqzqNhMHfuA1zr4NgUkaivF-dQr84Z4WA4i1crmR-BA7tMIQti7rDjtmIxQATfKrNw1zD5yYrDiI2jOkUAGiJ1hIY0Ue-x0wmykzktwD_xIsixxX3IOeqgY39gZ7XmwRYA4boZsSbWqqmVDgjBElaUYCUKlp_t-50vHeMNySt5AHDwmY3cOb0zePMEVYzQiKMOTRsSMrAavnIquY6BHytWKOJuuOoWS5aTiuy1YGw6wMQZT7MFcza9u4iYjJm39cdLnGl4tWn8StvawbXepPFqrwcoJXAfkvd8f--eCPuAXIFi__EMM0jlO2PGSbj-5YjFnCdKspnycrlLB6
|
||||
@@ -1,9 +0,0 @@
|
||||
To update the colors to the specified values, we will convert the hex color codes to RGBA format (values between 0 and 1) and update the `server_box_color` property in the `HomeScreen` class.
|
||||
|
||||
Here are the RGBA equivalents of the provided hex colors:
|
||||
|
||||
- **Yellow (#FB8D14)**: `(0.984, 0.553, 0.078, 1)`
|
||||
- **Red (#E8083E)**: `(0.909, 0.031, 0.243, 1)`
|
||||
- **Green (#02864A)**: `(0.008, 0.525, 0.290, 1)`
|
||||
The RGBA equivalent of `#573CFA` is `(0.341, 0.235, 0.980, 1)`.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
python -m venv track
|
||||
source track/bin/activate
|
||||
pip install -r reqirements.txt
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
import requests
|
||||
|
||||
def get_device_route(server_url, token, device_id, from_time, to_time):
|
||||
"""
|
||||
Fetch all positions for a device in a time frame from Traccar server.
|
||||
"""
|
||||
url = f"{server_url}/reports/route"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
params = {
|
||||
"deviceId": device_id,
|
||||
"from": from_time,
|
||||
"to": to_time
|
||||
}
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
positions = response.json()
|
||||
print(f"Retrieved {len(positions)} positions.")
|
||||
return positions
|
||||
except Exception as e:
|
||||
print(f"Error parsing JSON: {e}")
|
||||
print(response.text)
|
||||
return []
|
||||
else:
|
||||
print(f"Failed to fetch positions: {response.status_code} - {response.text}")
|
||||
return []
|
||||
|
||||
# Example usage:
|
||||
if __name__ == "__main__":
|
||||
# Use your actual Traccar API endpoint (not /reports/route)
|
||||
server_url = "https://gps.moto-adv.com/api"
|
||||
token = "SDBGAiEA4sNXvVhL8w_Jd-5oCiXAuS5La5yelCQemfNysZYItaMCIQDOkzaoWKHbNnbZJw9ruGlsvbp35d90x3EGOZLXW_Gls3sidSI6MSwiZSI6IjIwMjUtMDYtMDlUMjE6MDA6MDAuMDAwKzAwOjAwIn0"
|
||||
device_id = 1 # Replace with your device ID
|
||||
from_time = "2024-06-02T21:00:00Z"
|
||||
to_time = "2025-06-03T20:59:00Z"
|
||||
positions = get_device_route(server_url, token, device_id, from_time, to_time)
|
||||
for pos in positions:
|
||||
print(f"{pos['deviceTime']}: {pos['latitude']}, {pos['longitude']}")
|
||||
@@ -1 +0,0 @@
|
||||
SDBGAiEA4sNXvVhL8w_Jd-5oCiXAuS5La5yelCQemfNysZYItaMCIQDOkzaoWKHbNnbZJw9ruGlsvbp35d90x3EGOZLXW_Gls3sidSI6MSwiZSI6IjIwMjUtMDYtMDlUMjE6MDA6MDAuMDAwKzAwOjAwIn0
|
||||
0
screens/__init__.py
Normal file
0
screens/__init__.py
Normal file
941
screens/create_animation_screen.py
Normal file
941
screens/create_animation_screen.py
Normal file
@@ -0,0 +1,941 @@
|
||||
import kivy
|
||||
from kivy.uix.screenmanager import Screen
|
||||
import os
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||
from py_scripts.utils import (
|
||||
process_preview_util, optimize_route_entries_util
|
||||
)
|
||||
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||
# BlenderGPSAnimator imported conditionally when needed
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
from kivy.uix.textinput import TextInput
|
||||
from config import RESOURCES_FOLDER
|
||||
|
||||
class CreateAnimationScreen(Screen):
|
||||
project_name = StringProperty("")
|
||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||
preview_image_path = StringProperty("") # Add this line
|
||||
preview_image_version = NumericProperty(0) # Add this line
|
||||
|
||||
def get_preview_image_source(self):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
img_path = os.path.join(project_folder, "preview.png")
|
||||
if os.path.exists(img_path):
|
||||
return img_path
|
||||
return "resources/images/track.png"
|
||||
|
||||
preview_image_source = AliasProperty(
|
||||
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||
)
|
||||
|
||||
def on_pre_enter(self):
|
||||
# Update the route entries label with the actual number of entries
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
count = 0
|
||||
if os.path.exists(positions_path):
|
||||
with open(positions_path, "r") as f:
|
||||
try:
|
||||
positions = json.load(f)
|
||||
count = len(positions)
|
||||
except Exception:
|
||||
count = 0
|
||||
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||
|
||||
def open_rename_popup(self):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Enter new project name:")
|
||||
input_field = TextInput(text=self.project_name, multiline=False)
|
||||
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(input_field)
|
||||
layout.add_widget(btn_save)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
popup = Popup(
|
||||
title="Rename Project",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def do_rename(instance):
|
||||
new_name = input_field.text.strip()
|
||||
if new_name and new_name != self.project_name:
|
||||
if self.rename_project_folder(self.project_name, new_name):
|
||||
self.project_name = new_name
|
||||
popup.dismiss()
|
||||
self.on_pre_enter() # Refresh label
|
||||
else:
|
||||
label.text = "Rename failed (name exists?)"
|
||||
else:
|
||||
label.text = "Please enter a new name."
|
||||
|
||||
btn_save.bind(on_press=do_rename)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def rename_project_folder(self, old_name, new_name):
|
||||
import os
|
||||
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||
os.rename(old_path, new_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def optimize_route_entries(self):
|
||||
# Create the popup and UI elements
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route entries...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Optimizing Route",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
# Now call the utility function with these objects
|
||||
optimize_route_entries_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
Clock,
|
||||
on_save=lambda: self.on_pre_enter()
|
||||
)
|
||||
|
||||
def preview_route(self):
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route preview...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Previewing Route",
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 180),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def set_preview_image_path(path):
|
||||
self.preview_image_path = path
|
||||
self.preview_image_version += 1 # Force AliasProperty to update
|
||||
self.property('preview_image_source').dispatch(self)
|
||||
self.ids.preview_image.reload()
|
||||
# Schedule the processing function
|
||||
Clock.schedule_once(
|
||||
lambda dt: process_preview_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
self.ids.preview_image,
|
||||
set_preview_image_path,
|
||||
Clock
|
||||
),
|
||||
0.5
|
||||
)
|
||||
|
||||
def generate_google_earth_animation(self):
|
||||
"""Generate Google Earth-style flythrough animation using NavigationAnimationGenerator"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing Google Earth flythrough...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Google Earth Flythrough",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_google_earth_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Loading GPS data...")
|
||||
|
||||
# Check dependencies first
|
||||
generator = NavigationAnimationGenerator(project_folder)
|
||||
generator.check_dependencies()
|
||||
|
||||
update_status(20, "Processing GPS coordinates...")
|
||||
df = generator.load_gps_data(positions_path)
|
||||
|
||||
update_status(40, "Creating Google Earth flythrough...")
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_google_earth_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the generator
|
||||
def generator_progress(progress, message):
|
||||
update_status(40 + (progress * 0.5), message) # Map 0-100% to 40-90%
|
||||
|
||||
update_status(90, "Creating flythrough video...")
|
||||
success = generator.generate_frames(positions_path, style='google_earth', progress_callback=generator_progress)
|
||||
|
||||
if success and len(success) > 0:
|
||||
update_status(95, "Rendering final video...")
|
||||
video_success = generator.create_video(success, output_video_path, generator_progress)
|
||||
if video_success:
|
||||
update_status(100, "Google Earth flythrough complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to create video from frames")
|
||||
else:
|
||||
raise Exception("Failed to generate frames")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Google Earth Flythrough Complete!",
|
||||
f"Your Google Earth-style flythrough has been saved to:\n{output_path}",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||
|
||||
def generate_blender_animation(self):
|
||||
"""Generate cinema-quality animation using Blender (or fallback to advanced 3D)"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing cinema rendering pipeline...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Cinema Animation",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_blender_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
# Check if Blender is available
|
||||
try:
|
||||
from py_scripts.blender_animator import BLENDER_AVAILABLE, BlenderGPSAnimator
|
||||
if BLENDER_AVAILABLE:
|
||||
update_status(10, "Loading GPS data into Blender...")
|
||||
|
||||
# Use Blender for rendering
|
||||
animator = BlenderGPSAnimator(project_folder)
|
||||
animator.check_dependencies()
|
||||
|
||||
update_status(25, "Processing GPS coordinates...")
|
||||
gps_data = animator.load_gps_data(positions_path)
|
||||
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the animator
|
||||
def animator_progress(progress, message):
|
||||
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||
|
||||
update_status(85, "Rendering cinema-quality video...")
|
||||
success = animator.create_gps_animation(
|
||||
positions_path,
|
||||
output_video_path,
|
||||
progress_callback=animator_progress
|
||||
)
|
||||
|
||||
if success:
|
||||
update_status(100, "Blender cinema animation complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to generate Blender animation")
|
||||
|
||||
else:
|
||||
raise ImportError("Blender not available")
|
||||
|
||||
except ImportError:
|
||||
# Fallback to advanced 3D animation with cinema-quality settings
|
||||
update_status(10, "Blender not available - using advanced 3D cinema mode...")
|
||||
|
||||
# Import here to avoid startup delays
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from mpl_toolkits.mplot3d import Axes3D
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
# Load GPS data
|
||||
with open(positions_path, 'r') as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if len(positions) < 2:
|
||||
update_status(0, "Error: Need at least 2 GPS points")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(20, "Processing GPS coordinates for cinema rendering...")
|
||||
|
||||
# Extract coordinates
|
||||
lats = np.array([pos['latitude'] for pos in positions])
|
||||
lons = np.array([pos['longitude'] for pos in positions])
|
||||
alts = np.array([pos.get('altitude', 0) for pos in positions])
|
||||
timestamps = [pos.get('fixTime', '') for pos in positions]
|
||||
|
||||
# Convert to relative coordinates
|
||||
lat_center = np.mean(lats)
|
||||
lon_center = np.mean(lons)
|
||||
alt_min = np.min(alts)
|
||||
|
||||
x = (lons - lon_center) * 111320 * np.cos(np.radians(lat_center))
|
||||
y = (lats - lat_center) * 110540
|
||||
z = alts - alt_min
|
||||
|
||||
update_status(30, "Creating cinema-quality frames...")
|
||||
|
||||
# Cinema settings - higher quality
|
||||
frames_folder = os.path.join(project_folder, "cinema_frames")
|
||||
os.makedirs(frames_folder, exist_ok=True)
|
||||
|
||||
fps = 24 # Cinema standard
|
||||
total_frames = min(len(positions), 200) # Limit for reasonable processing time
|
||||
points_per_frame = max(1, len(positions) // total_frames)
|
||||
|
||||
frame_files = []
|
||||
|
||||
# Generate cinema-quality frames
|
||||
for frame_idx in range(total_frames):
|
||||
current_progress = 30 + (frame_idx / total_frames) * 50
|
||||
update_status(current_progress, f"Rendering cinema frame {frame_idx + 1}/{total_frames}...")
|
||||
|
||||
end_point = min((frame_idx + 1) * points_per_frame, len(positions))
|
||||
|
||||
# Create high-quality 3D plot
|
||||
plt.style.use('dark_background') # Cinema-style dark theme
|
||||
fig = plt.figure(figsize=(16, 12), dpi=150) # Higher resolution
|
||||
ax = fig.add_subplot(111, projection='3d')
|
||||
|
||||
# Plot route with cinema styling
|
||||
if end_point > 1:
|
||||
# Gradient effect for completed route
|
||||
colors = np.linspace(0, 1, end_point)
|
||||
ax.scatter(x[:end_point], y[:end_point], z[:end_point],
|
||||
c=colors, cmap='plasma', s=30, alpha=0.8)
|
||||
ax.plot(x[:end_point], y[:end_point], z[:end_point],
|
||||
color='cyan', linewidth=3, alpha=0.9)
|
||||
|
||||
# Current position with glow effect
|
||||
if end_point > 0:
|
||||
current_idx = end_point - 1
|
||||
# Multiple layers for glow effect
|
||||
for size, alpha in [(200, 0.3), (150, 0.5), (100, 0.8)]:
|
||||
ax.scatter(x[current_idx], y[current_idx], z[current_idx],
|
||||
c='yellow', s=size, alpha=alpha, marker='o')
|
||||
|
||||
# Trail effect
|
||||
trail_start = max(0, current_idx - 10)
|
||||
if current_idx > trail_start:
|
||||
trail_alpha = np.linspace(0.3, 1.0, current_idx - trail_start + 1)
|
||||
for i, alpha in enumerate(trail_alpha):
|
||||
idx = trail_start + i
|
||||
ax.scatter(x[idx], y[idx], z[idx],
|
||||
c='orange', s=60, alpha=alpha)
|
||||
|
||||
# Remaining route preview
|
||||
if end_point < len(positions):
|
||||
ax.plot(x[end_point:], y[end_point:], z[end_point:],
|
||||
color='gray', linewidth=1, alpha=0.4, linestyle='--')
|
||||
|
||||
# Cinema-style labels and styling
|
||||
ax.set_xlabel('East-West (m)', color='white', fontsize=14)
|
||||
ax.set_ylabel('North-South (m)', color='white', fontsize=14)
|
||||
ax.set_zlabel('Elevation (m)', color='white', fontsize=14)
|
||||
|
||||
# Progress and time info
|
||||
progress_percent = (end_point / len(positions)) * 100
|
||||
timestamp_str = timestamps[end_point-1] if end_point > 0 else "Start"
|
||||
ax.set_title(f'CINEMA GPS JOURNEY\nProgress: {progress_percent:.1f}% • Point {end_point}/{len(positions)} • {timestamp_str}',
|
||||
color='white', fontsize=16, pad=20, weight='bold')
|
||||
|
||||
# Consistent view with cinematic angle
|
||||
margin = max(np.ptp(x), np.ptp(y)) * 0.15
|
||||
ax.set_xlim(np.min(x) - margin, np.max(x) + margin)
|
||||
ax.set_ylim(np.min(y) - margin, np.max(y) + margin)
|
||||
ax.set_zlim(np.min(z) - 20, np.max(z) + 20)
|
||||
|
||||
# Dynamic camera movement for cinematic effect
|
||||
azim = 45 + (frame_idx * 0.5) % 360 # Slowly rotating view
|
||||
ax.view_init(elev=25, azim=azim)
|
||||
|
||||
# Cinema-style grid
|
||||
ax.grid(True, alpha=0.2, color='white')
|
||||
ax.xaxis.pane.fill = False
|
||||
ax.yaxis.pane.fill = False
|
||||
ax.zaxis.pane.fill = False
|
||||
|
||||
# Make pane edges more subtle
|
||||
ax.xaxis.pane.set_edgecolor('gray')
|
||||
ax.yaxis.pane.set_edgecolor('gray')
|
||||
ax.zaxis.pane.set_edgecolor('gray')
|
||||
ax.xaxis.pane.set_alpha(0.1)
|
||||
ax.yaxis.pane.set_alpha(0.1)
|
||||
ax.zaxis.pane.set_alpha(0.1)
|
||||
|
||||
# Save high-quality frame
|
||||
frame_path = os.path.join(frames_folder, f"cinema_frame_{frame_idx:04d}.png")
|
||||
try:
|
||||
plt.savefig(frame_path, dpi=150, bbox_inches='tight',
|
||||
facecolor='black', edgecolor='none', format='png')
|
||||
plt.close(fig)
|
||||
|
||||
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1024:
|
||||
test_frame = cv2.imread(frame_path)
|
||||
if test_frame is not None:
|
||||
frame_files.append(frame_path)
|
||||
if frame_idx == 0:
|
||||
h, w, c = test_frame.shape
|
||||
update_status(current_progress, f"Cinema quality: {w}x{h} at {fps} FPS")
|
||||
except Exception as frame_error:
|
||||
update_status(current_progress, f"Error creating frame {frame_idx}: {str(frame_error)}")
|
||||
plt.close(fig)
|
||||
continue
|
||||
|
||||
plt.style.use('default') # Reset style
|
||||
|
||||
# Create cinema video
|
||||
if not frame_files:
|
||||
raise Exception("No valid cinema frames were generated")
|
||||
|
||||
update_status(80, f"Creating cinema video from {len(frame_files)} frames...")
|
||||
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_cinema_3d_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Cinema video creation with higher quality
|
||||
first_frame = cv2.imread(frame_files[0])
|
||||
height, width, layers = first_frame.shape
|
||||
|
||||
# Try to create high-quality video
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
video_writer = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
|
||||
|
||||
if video_writer.isOpened():
|
||||
for i, frame_file in enumerate(frame_files):
|
||||
frame = cv2.imread(frame_file)
|
||||
if frame is not None:
|
||||
video_writer.write(frame)
|
||||
|
||||
if i % 10 == 0:
|
||||
progress = 80 + (i / len(frame_files)) * 8
|
||||
update_status(progress, f"Encoding cinema frame {i+1}/{len(frame_files)}")
|
||||
|
||||
video_writer.release()
|
||||
|
||||
if os.path.exists(output_video_path) and os.path.getsize(output_video_path) > 1024:
|
||||
update_status(90, "Cinema video created successfully")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Cinema video creation failed")
|
||||
else:
|
||||
raise Exception("Could not initialize cinema video writer")
|
||||
|
||||
# Clean up frames
|
||||
for frame_file in frame_files:
|
||||
try:
|
||||
os.remove(frame_file)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(frames_folder)
|
||||
except:
|
||||
pass
|
||||
|
||||
update_status(100, "Cinema animation complete!")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Cinema Animation Complete!",
|
||||
f"Your cinema-quality animation has been saved to:\n{output_path}\n\nNote: Blender was not available, so advanced 3D cinema mode was used instead.",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
print(f"DEBUG: Cinema animation error: {error_message}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Cinema Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||
|
||||
def generate_progressive_3d_animation(self):
|
||||
"""Generate a progressive 3D animation that builds the trip point by point"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing progressive 3D animation...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Progressive 3D Animation",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_progressive_animation():
|
||||
try:
|
||||
# Import here to avoid startup delays
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # Use non-interactive backend
|
||||
import matplotlib.pyplot as plt
|
||||
from mpl_toolkits.mplot3d import Axes3D
|
||||
import numpy as np
|
||||
import cv2 # Use OpenCV instead of MoviePy
|
||||
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Loading GPS data...")
|
||||
|
||||
# Load GPS data
|
||||
with open(positions_path, 'r') as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if len(positions) < 2:
|
||||
update_status(0, "Error: Need at least 2 GPS points")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(20, "Processing GPS coordinates...")
|
||||
|
||||
# Extract coordinates and timestamps
|
||||
lats = [pos['latitude'] for pos in positions]
|
||||
lons = [pos['longitude'] for pos in positions]
|
||||
alts = [pos.get('altitude', 0) for pos in positions]
|
||||
timestamps = [pos.get('fixTime', '') for pos in positions]
|
||||
|
||||
# Convert to numpy arrays for easier manipulation
|
||||
lats = np.array(lats)
|
||||
lons = np.array(lons)
|
||||
alts = np.array(alts)
|
||||
|
||||
# Normalize coordinates for better visualization
|
||||
lat_center = np.mean(lats)
|
||||
lon_center = np.mean(lons)
|
||||
alt_min = np.min(alts)
|
||||
|
||||
# Convert to relative coordinates (in meters approximately)
|
||||
x = (lons - lon_center) * 111320 * np.cos(np.radians(lat_center)) # longitude to meters
|
||||
y = (lats - lat_center) * 110540 # latitude to meters
|
||||
z = alts - alt_min # relative altitude
|
||||
|
||||
update_status(30, "Creating animation frames...")
|
||||
|
||||
# Create frames folder
|
||||
frames_folder = os.path.join(project_folder, "progressive_frames")
|
||||
os.makedirs(frames_folder, exist_ok=True)
|
||||
|
||||
# Animation settings
|
||||
fps = 10 # frames per second
|
||||
points_per_frame = max(1, len(positions) // 100) # Show multiple points per frame for long routes
|
||||
total_frames = len(positions) // points_per_frame
|
||||
|
||||
frame_files = []
|
||||
|
||||
# Generate frames
|
||||
for frame_idx in range(total_frames):
|
||||
current_progress = 30 + (frame_idx / total_frames) * 50
|
||||
update_status(current_progress, f"Creating frame {frame_idx + 1}/{total_frames}...")
|
||||
|
||||
# Points to show in this frame
|
||||
end_point = min((frame_idx + 1) * points_per_frame, len(positions))
|
||||
|
||||
# Create 3D plot
|
||||
fig = plt.figure(figsize=(12, 9), dpi=100)
|
||||
ax = fig.add_subplot(111, projection='3d')
|
||||
|
||||
# Plot the route progressively
|
||||
if end_point > 1:
|
||||
# Plot completed route in blue
|
||||
ax.plot(x[:end_point], y[:end_point], z[:end_point],
|
||||
'b-', linewidth=2, alpha=0.7, label='Route')
|
||||
|
||||
# Plot points as small dots
|
||||
ax.scatter(x[:end_point], y[:end_point], z[:end_point],
|
||||
c='blue', s=20, alpha=0.6)
|
||||
|
||||
# Highlight current position in red
|
||||
if end_point > 0:
|
||||
current_idx = end_point - 1
|
||||
ax.scatter(x[current_idx], y[current_idx], z[current_idx],
|
||||
c='red', s=100, marker='o', label='Current Position')
|
||||
|
||||
# Add a small trail behind current position
|
||||
trail_start = max(0, current_idx - 5)
|
||||
if current_idx > trail_start:
|
||||
ax.plot(x[trail_start:current_idx+1],
|
||||
y[trail_start:current_idx+1],
|
||||
z[trail_start:current_idx+1],
|
||||
'r-', linewidth=4, alpha=0.8)
|
||||
|
||||
# Plot remaining route in light gray (preview)
|
||||
if end_point < len(positions):
|
||||
ax.plot(x[end_point:], y[end_point:], z[end_point:],
|
||||
'lightgray', linewidth=1, alpha=0.3, label='Remaining Route')
|
||||
|
||||
# Set labels and title
|
||||
ax.set_xlabel('East-West (meters)')
|
||||
ax.set_ylabel('North-South (meters)')
|
||||
ax.set_zlabel('Elevation (meters)')
|
||||
|
||||
# Add progress info
|
||||
progress_percent = (end_point / len(positions)) * 100
|
||||
timestamp_str = timestamps[end_point-1] if end_point > 0 else "Start"
|
||||
ax.set_title(f'GPS Trip Animation\nProgress: {progress_percent:.1f}% - Point {end_point}/{len(positions)}\nTime: {timestamp_str}',
|
||||
fontsize=14, pad=20)
|
||||
|
||||
# Set consistent view limits for all frames
|
||||
margin = max(np.ptp(x), np.ptp(y)) * 0.1
|
||||
ax.set_xlim(np.min(x) - margin, np.max(x) + margin)
|
||||
ax.set_ylim(np.min(y) - margin, np.max(y) + margin)
|
||||
ax.set_zlim(np.min(z) - 10, np.max(z) + 10)
|
||||
|
||||
# Set viewing angle for better 3D perspective
|
||||
ax.view_init(elev=20, azim=45)
|
||||
|
||||
# Add legend
|
||||
ax.legend(loc='upper right')
|
||||
|
||||
# Add grid
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Save frame with comprehensive error handling
|
||||
frame_path = os.path.join(frames_folder, f"frame_{frame_idx:04d}.png")
|
||||
try:
|
||||
plt.savefig(frame_path, dpi=100, bbox_inches='tight',
|
||||
facecolor='white', edgecolor='none',
|
||||
format='png', optimize=False)
|
||||
plt.close(fig)
|
||||
|
||||
# Verify frame was saved properly and is readable by OpenCV
|
||||
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1024:
|
||||
# Test if OpenCV can read the frame
|
||||
test_frame = cv2.imread(frame_path)
|
||||
if test_frame is not None:
|
||||
frame_files.append(frame_path)
|
||||
if frame_idx == 0: # Log first frame details
|
||||
h, w, c = test_frame.shape
|
||||
update_status(current_progress, f"First frame: {w}x{h}, size: {os.path.getsize(frame_path)} bytes")
|
||||
else:
|
||||
update_status(current_progress, f"Warning: Frame {frame_idx} not readable by OpenCV")
|
||||
try:
|
||||
os.remove(frame_path)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
update_status(current_progress, f"Warning: Frame {frame_idx} too small or missing")
|
||||
|
||||
except Exception as e:
|
||||
update_status(current_progress, f"Error saving frame {frame_idx}: {str(e)}")
|
||||
try:
|
||||
plt.close(fig)
|
||||
except:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Validate frames before creating video
|
||||
if not frame_files:
|
||||
raise Exception("No valid frames were generated")
|
||||
|
||||
update_status(80, f"Creating video from {len(frame_files)} frames...")
|
||||
|
||||
# Create video using OpenCV with better error handling
|
||||
output_video_path = os.path.join(project_folder,
|
||||
f"{self.project_name}_progressive_3d_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
if frame_files:
|
||||
try:
|
||||
# Read first frame to get dimensions
|
||||
first_frame = cv2.imread(frame_files[0])
|
||||
if first_frame is None:
|
||||
raise Exception(f"Could not read first frame: {frame_files[0]}")
|
||||
|
||||
height, width, layers = first_frame.shape
|
||||
update_status(82, f"Video dimensions: {width}x{height}")
|
||||
|
||||
# Try different codecs for better compatibility
|
||||
codecs_to_try = [
|
||||
('mp4v', '.mp4'),
|
||||
('XVID', '.avi'),
|
||||
('MJPG', '.avi')
|
||||
]
|
||||
|
||||
video_created = False
|
||||
|
||||
for codec, ext in codecs_to_try:
|
||||
try:
|
||||
# Update output path for different codecs
|
||||
if ext != '.mp4':
|
||||
test_output_path = output_video_path.replace('.mp4', ext)
|
||||
else:
|
||||
test_output_path = output_video_path
|
||||
|
||||
update_status(84, f"Trying codec {codec}...")
|
||||
|
||||
# Create video writer
|
||||
fourcc = cv2.VideoWriter_fourcc(*codec)
|
||||
video_writer = cv2.VideoWriter(test_output_path, fourcc, fps, (width, height))
|
||||
|
||||
if not video_writer.isOpened():
|
||||
update_status(85, f"Failed to open video writer with {codec}")
|
||||
continue
|
||||
|
||||
# Add frames to video
|
||||
frames_written = 0
|
||||
for i, frame_file in enumerate(frame_files):
|
||||
frame = cv2.imread(frame_file)
|
||||
if frame is not None:
|
||||
# Ensure frame dimensions match
|
||||
if frame.shape[:2] != (height, width):
|
||||
frame = cv2.resize(frame, (width, height))
|
||||
video_writer.write(frame)
|
||||
frames_written += 1
|
||||
|
||||
if i % 10 == 0: # Update progress every 10 frames
|
||||
progress = 85 + (i / len(frame_files)) * 3
|
||||
update_status(progress, f"Writing frame {i+1}/{len(frame_files)} with {codec}")
|
||||
|
||||
video_writer.release()
|
||||
|
||||
# Check if video file was created and has reasonable size
|
||||
if os.path.exists(test_output_path) and os.path.getsize(test_output_path) > 1024:
|
||||
output_video_path = test_output_path
|
||||
video_created = True
|
||||
update_status(88, f"Video created successfully with {codec} ({frames_written} frames)")
|
||||
break
|
||||
else:
|
||||
update_status(86, f"Video file not created or too small with {codec}")
|
||||
|
||||
except Exception as codec_error:
|
||||
update_status(87, f"Error with {codec}: {str(codec_error)}")
|
||||
continue
|
||||
|
||||
if not video_created:
|
||||
raise Exception("Failed to create video with any codec")
|
||||
|
||||
except Exception as video_error:
|
||||
raise Exception(f"Video creation failed: {str(video_error)}")
|
||||
|
||||
update_status(90, "Cleaning up temporary files...")
|
||||
|
||||
# Clean up frame files
|
||||
for frame_file in frame_files:
|
||||
try:
|
||||
os.remove(frame_file)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(frames_folder)
|
||||
except:
|
||||
pass
|
||||
|
||||
update_status(100, "Progressive 3D animation complete!")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Progressive 3D Animation Complete!",
|
||||
f"Your progressive 3D animation has been saved to:\n{output_video_path}\n\nThe animation shows {len(positions)} GPS points building the route progressively from start to finish.",
|
||||
output_video_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
else:
|
||||
raise Exception("No frames were generated")
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
print(f"DEBUG: Progressive animation error: {error_message}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Progressive Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_progressive_animation(), 0.5)
|
||||
|
||||
def open_pauses_popup(self):
|
||||
"""Navigate to the pause edit screen"""
|
||||
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||
self.manager.current = "pause_edit"
|
||||
|
||||
def show_success_popup(self, title, message, file_path):
|
||||
"""Show success popup with option to open file location"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||
|
||||
# Success message
|
||||
success_label = Label(
|
||||
text=message,
|
||||
text_size=(None, None),
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
layout.add_widget(success_label)
|
||||
|
||||
# Buttons
|
||||
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||
|
||||
open_folder_btn = Button(
|
||||
text="Open Folder",
|
||||
background_color=(0.2, 0.6, 0.9, 1)
|
||||
)
|
||||
|
||||
ok_btn = Button(
|
||||
text="OK",
|
||||
background_color=(0.3, 0.7, 0.3, 1)
|
||||
)
|
||||
|
||||
btn_layout.add_widget(open_folder_btn)
|
||||
btn_layout.add_widget(ok_btn)
|
||||
layout.add_widget(btn_layout)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.9, 0.6),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def open_folder(instance):
|
||||
folder_path = os.path.dirname(file_path)
|
||||
os.system(f'xdg-open "{folder_path}"') # Linux
|
||||
popup.dismiss()
|
||||
|
||||
def close_popup(instance):
|
||||
popup.dismiss()
|
||||
|
||||
open_folder_btn.bind(on_press=open_folder)
|
||||
ok_btn.bind(on_press=close_popup)
|
||||
|
||||
popup.open()
|
||||
|
||||
def show_error_popup(self, title, message):
|
||||
"""Show error popup"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||
|
||||
error_label = Label(
|
||||
text=f"Error: {message}",
|
||||
text_size=(None, None),
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
layout.add_widget(error_label)
|
||||
|
||||
ok_btn = Button(
|
||||
text="OK",
|
||||
background_color=(0.8, 0.3, 0.3, 1),
|
||||
size_hint_y=None,
|
||||
height=50
|
||||
)
|
||||
layout.add_widget(ok_btn)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.8, 0.4),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
633
screens/create_animation_screen.py.backup
Normal file
633
screens/create_animation_screen.py.backup
Normal file
@@ -0,0 +1,633 @@
|
||||
import kivy
|
||||
from kivy.uix.screenmanager import Screen
|
||||
import os
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||
from py_scripts.utils import (
|
||||
process_preview_util, optimize_route_entries_util
|
||||
)
|
||||
|
||||
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||
from py_scripts.blender_animator import BlenderGPSAnimator
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
from kivy.uix.textinput import TextInput
|
||||
from config import RESOURCES_FOLDER
|
||||
|
||||
class CreateAnimationScreen(Screen):
|
||||
project_name = StringProperty("")
|
||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||
preview_image_path = StringProperty("") # Add this line
|
||||
preview_image_version = NumericProperty(0) # Add this line
|
||||
|
||||
def get_preview_image_source(self):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
img_path = os.path.join(project_folder, "preview.png")
|
||||
if os.path.exists(img_path):
|
||||
return img_path
|
||||
return "resources/images/track.png"
|
||||
|
||||
preview_image_source = AliasProperty(
|
||||
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||
)
|
||||
|
||||
def on_pre_enter(self):
|
||||
# Update the route entries label with the actual number of entries
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
count = 0
|
||||
if os.path.exists(positions_path):
|
||||
with open(positions_path, "r") as f:
|
||||
try:
|
||||
positions = json.load(f)
|
||||
count = len(positions)
|
||||
except Exception:
|
||||
count = 0
|
||||
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||
|
||||
|
||||
|
||||
def open_rename_popup(self):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Enter new project name:")
|
||||
input_field = TextInput(text=self.project_name, multiline=False)
|
||||
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(input_field)
|
||||
layout.add_widget(btn_save)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
popup = Popup(
|
||||
title="Rename Project",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def do_rename(instance):
|
||||
new_name = input_field.text.strip()
|
||||
if new_name and new_name != self.project_name:
|
||||
if self.rename_project_folder(self.project_name, new_name):
|
||||
self.project_name = new_name
|
||||
popup.dismiss()
|
||||
self.on_pre_enter() # Refresh label
|
||||
else:
|
||||
label.text = "Rename failed (name exists?)"
|
||||
else:
|
||||
label.text = "Please enter a new name."
|
||||
|
||||
btn_save.bind(on_press=do_rename)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def rename_project_folder(self, old_name, new_name):
|
||||
import os
|
||||
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||
os.rename(old_path, new_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def optimize_route_entries(self):
|
||||
# Create the popup and UI elements
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route entries...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Optimizing Route",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
# Now call the utility function with these objects
|
||||
optimize_route_entries_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
Clock,
|
||||
on_save=lambda: self.on_pre_enter()
|
||||
)
|
||||
|
||||
|
||||
def preview_route(self):
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route preview...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Previewing Route",
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 180),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def set_preview_image_path(path):
|
||||
self.preview_image_path = path
|
||||
self.preview_image_version += 1 # Force AliasProperty to update
|
||||
self.property('preview_image_source').dispatch(self)
|
||||
self.ids.preview_image.reload()
|
||||
# Schedule the processing function
|
||||
Clock.schedule_once(
|
||||
lambda dt: process_preview_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
self.ids.preview_image,
|
||||
set_preview_image_path,
|
||||
Clock
|
||||
),
|
||||
0.5
|
||||
)
|
||||
|
||||
def open_pauses_popup(self):
|
||||
"""Navigate to the pause edit screen"""
|
||||
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||
self.manager.current = "pause_edit"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def show_video_generation_options(self):
|
||||
"""Show popup with video generation mode options including new advanced animations"""
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=12, padding=15)
|
||||
|
||||
# Title
|
||||
title_label = Label(
|
||||
text="Choose Animation Style & Quality",
|
||||
font_size=20,
|
||||
size_hint_y=None,
|
||||
height=40,
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
layout.add_widget(title_label)
|
||||
|
||||
# Classic 3D Mode
|
||||
classic_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||
classic_title = Label(
|
||||
text="🏃♂️ Classic 3D (Original Pipeline)",
|
||||
font_size=16,
|
||||
size_hint_y=None,
|
||||
height=30,
|
||||
color=(0.2, 0.8, 0.2, 1)
|
||||
)
|
||||
classic_desc = Label(
|
||||
text="• Traditional OpenCV/PIL approach\n• Fast generation\n• Good for simple tracks\n• Test (720p) or Production (2K)",
|
||||
font_size=11,
|
||||
size_hint_y=None,
|
||||
height=70,
|
||||
color=(0.9, 0.9, 0.9, 1),
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
classic_desc.text_size = (None, None)
|
||||
classic_layout.add_widget(classic_title)
|
||||
classic_layout.add_widget(classic_desc)
|
||||
layout.add_widget(classic_layout)
|
||||
|
||||
# Classic buttons
|
||||
classic_btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=45)
|
||||
classic_test_btn = Button(
|
||||
text="Classic 720p",
|
||||
background_color=(0.2, 0.8, 0.2, 1),
|
||||
font_size=12
|
||||
)
|
||||
classic_prod_btn = Button(
|
||||
text="Classic 2K",
|
||||
background_color=(0.3, 0.6, 0.3, 1),
|
||||
font_size=12
|
||||
)
|
||||
classic_btn_layout.add_widget(classic_test_btn)
|
||||
classic_btn_layout.add_widget(classic_prod_btn)
|
||||
layout.add_widget(classic_btn_layout)
|
||||
|
||||
# Advanced Navigation Mode
|
||||
advanced_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||
advanced_title = Label(
|
||||
text="🧭 Navigation Animation",
|
||||
font_size=16,
|
||||
size_hint_y=None,
|
||||
height=30,
|
||||
color=(0.2, 0.6, 0.9, 1)
|
||||
)
|
||||
advanced_desc = Label(
|
||||
text="• Satellite terrain details\n• 3D camera following at 1000-2000m\n• Google Earth entry scene\n• Professional navigation view",
|
||||
font_size=11,
|
||||
size_hint_y=None,
|
||||
height=70,
|
||||
color=(0.9, 0.9, 0.9, 1),
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
advanced_desc.text_size = (None, None)
|
||||
advanced_layout.add_widget(advanced_title)
|
||||
advanced_layout.add_widget(advanced_desc)
|
||||
layout.add_widget(advanced_layout)
|
||||
|
||||
# Advanced button
|
||||
advanced_btn = Button(
|
||||
text="Generate Navigation Animation",
|
||||
background_color=(0.2, 0.6, 0.9, 1),
|
||||
size_hint_y=None,
|
||||
height=45,
|
||||
font_size=13
|
||||
)
|
||||
layout.add_widget(advanced_btn)
|
||||
|
||||
# Google Earth Flythrough Mode
|
||||
google_earth_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||
google_earth_title = Label(
|
||||
text="🌍 Google Earth Flythrough",
|
||||
font_size=16,
|
||||
size_hint_y=None,
|
||||
height=30,
|
||||
color=(0.1, 0.8, 0.1, 1)
|
||||
)
|
||||
google_earth_desc = Label(
|
||||
text="• Realistic 3D terrain with mountains\n• Cinematic camera following at 1000-2000m\n• Google Earth-style flythrough\n• Professional geographic animation",
|
||||
font_size=11,
|
||||
size_hint_y=None,
|
||||
height=70,
|
||||
color=(0.9, 0.9, 0.9, 1),
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
google_earth_desc.text_size = (None, None)
|
||||
google_earth_layout.add_widget(google_earth_title)
|
||||
google_earth_layout.add_widget(google_earth_desc)
|
||||
layout.add_widget(google_earth_layout)
|
||||
|
||||
# Google Earth button
|
||||
google_earth_btn = Button(
|
||||
text="Generate Google Earth Flythrough",
|
||||
background_color=(0.1, 0.8, 0.1, 1),
|
||||
size_hint_y=None,
|
||||
height=45,
|
||||
font_size=13
|
||||
)
|
||||
layout.add_widget(google_earth_btn)
|
||||
|
||||
# Blender Cinema Mode
|
||||
blender_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||
blender_title = Label(
|
||||
text="<22> Cinema Quality (Blender)",
|
||||
font_size=16,
|
||||
size_hint_y=None,
|
||||
height=30,
|
||||
color=(0.9, 0.6, 0.2, 1)
|
||||
)
|
||||
blender_desc = Label(
|
||||
text="• Professional 3D rendering\n• Photorealistic visuals\n• Cinema-grade quality\n• Longer processing time",
|
||||
font_size=11,
|
||||
size_hint_y=None,
|
||||
height=70,
|
||||
color=(0.9, 0.9, 0.9, 1),
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
blender_desc.text_size = (None, None)
|
||||
blender_layout.add_widget(blender_title)
|
||||
blender_layout.add_widget(blender_desc)
|
||||
layout.add_widget(blender_layout)
|
||||
|
||||
# Blender button
|
||||
blender_btn = Button(
|
||||
text="Generate Blender Cinema Animation",
|
||||
background_color=(0.9, 0.6, 0.2, 1),
|
||||
size_hint_y=None,
|
||||
height=45,
|
||||
font_size=13
|
||||
)
|
||||
layout.add_widget(blender_btn)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
background_color=(0.5, 0.5, 0.5, 1),
|
||||
size_hint_y=None,
|
||||
height=40,
|
||||
font_size=12
|
||||
)
|
||||
layout.add_widget(cancel_btn)
|
||||
|
||||
popup = Popup(
|
||||
title="Select Animation Style",
|
||||
content=layout,
|
||||
size_hint=(0.95, 0.9),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def start_classic_test(instance):
|
||||
popup.dismiss()
|
||||
self.generate_3d_video_test_mode()
|
||||
|
||||
def start_classic_production(instance):
|
||||
popup.dismiss()
|
||||
self.generate_3d_video_production_mode()
|
||||
|
||||
def start_advanced_3d(instance):
|
||||
popup.dismiss()
|
||||
self.generate_advanced_3d_animation()
|
||||
|
||||
def start_google_earth(instance):
|
||||
popup.dismiss()
|
||||
self.generate_google_earth_animation()
|
||||
|
||||
def start_blender_animation(instance):
|
||||
popup.dismiss()
|
||||
self.generate_blender_animation()
|
||||
|
||||
classic_test_btn.bind(on_press=start_classic_test)
|
||||
classic_prod_btn.bind(on_press=start_classic_production)
|
||||
advanced_btn.bind(on_press=start_advanced_3d)
|
||||
google_earth_btn.bind(on_press=start_google_earth)
|
||||
blender_btn.bind(on_press=start_blender_animation)
|
||||
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
|
||||
popup.open()
|
||||
|
||||
|
||||
|
||||
def generate_blender_animation(self):
|
||||
"""Generate cinema-quality animation using Blender"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing Blender rendering pipeline...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Blender Cinema Animation",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_blender_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Loading GPS data into Blender...")
|
||||
|
||||
# Check dependencies first
|
||||
animator = BlenderGPSAnimator(project_folder)
|
||||
animator.check_dependencies()
|
||||
|
||||
update_status(25, "Processing GPS coordinates...")
|
||||
gps_data = animator.load_gps_data(positions_path)
|
||||
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the animator
|
||||
def animator_progress(progress, message):
|
||||
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||
|
||||
update_status(85, "Rendering cinema-quality video...")
|
||||
success = animator.create_gps_animation(
|
||||
positions_path,
|
||||
output_video_path,
|
||||
progress_callback=animator_progress
|
||||
)
|
||||
|
||||
if success:
|
||||
update_status(100, "Blender cinema animation complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to render Blender animation")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Blender Cinema Animation Complete!",
|
||||
f"Your cinema-quality animation has been rendered to:\n{output_path}",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Blender Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||
|
||||
def generate_google_earth_animation(self):
|
||||
"""Generate Google Earth-style flythrough animation with terrain"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing Google Earth flythrough...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Google Earth Flythrough",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_google_earth_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Checking dependencies...")
|
||||
|
||||
# Check dependencies first
|
||||
generator = NavigationAnimationGenerator(project_folder)
|
||||
generator.check_dependencies()
|
||||
|
||||
update_status(20, "Loading GPS data...")
|
||||
df = generator.load_gps_data(positions_path)
|
||||
|
||||
update_status(30, "Generating navigation flythrough...")
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_navigation_flythrough_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the generator
|
||||
def generator_progress(progress, message):
|
||||
update_status(30 + (progress * 0.5), message) # Map 0-100% to 30-80%
|
||||
|
||||
update_status(80, "Creating navigation flythrough...")
|
||||
success = generator.generate_3d_animation(
|
||||
positions_path,
|
||||
output_video_path,
|
||||
style='advanced',
|
||||
progress_callback=generator_progress
|
||||
)
|
||||
|
||||
if success:
|
||||
update_status(100, "Navigation flythrough complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to generate navigation flythrough")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Google Earth Flythrough Complete!",
|
||||
f"Your cinematic flythrough has been created:\n{output_path}",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||
|
||||
def show_success_popup(self, title, message, file_path=None):
|
||||
"""Show success popup with option to open file location"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
success_label = Label(
|
||||
text=message,
|
||||
text_size=(400, None),
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
layout.add_widget(success_label)
|
||||
|
||||
button_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||
|
||||
if file_path:
|
||||
open_btn = Button(text="Open Folder", background_color=(0.2, 0.8, 0.2, 1))
|
||||
open_btn.bind(on_press=lambda x: self.open_file_location(file_path))
|
||||
button_layout.add_widget(open_btn)
|
||||
|
||||
ok_btn = Button(text="OK", background_color=(0.2, 0.6, 0.9, 1))
|
||||
button_layout.add_widget(ok_btn)
|
||||
layout.add_widget(button_layout)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 250),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def show_error_popup(self, title, message):
|
||||
"""Show error popup"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
error_label = Label(
|
||||
text=f"Error: {message}",
|
||||
text_size=(400, None),
|
||||
halign="center",
|
||||
valign="middle",
|
||||
color=(1, 0.3, 0.3, 1)
|
||||
)
|
||||
layout.add_widget(error_label)
|
||||
|
||||
ok_btn = Button(text="OK", background_color=(0.8, 0.2, 0.2, 1), size_hint_y=None, height=50)
|
||||
layout.add_widget(ok_btn)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def open_file_location(self, file_path):
|
||||
"""Open file location in system file manager"""
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
folder_path = os.path.dirname(file_path)
|
||||
|
||||
try:
|
||||
if platform.system() == "Linux":
|
||||
subprocess.run(["xdg-open", folder_path])
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
subprocess.run(["open", folder_path])
|
||||
elif platform.system() == "Windows":
|
||||
subprocess.run(["explorer", folder_path])
|
||||
except Exception as e:
|
||||
print(f"Could not open folder: {e}")
|
||||
|
||||
419
screens/create_animation_screen_clean.py
Normal file
419
screens/create_animation_screen_clean.py
Normal file
@@ -0,0 +1,419 @@
|
||||
import kivy
|
||||
from kivy.uix.screenmanager import Screen
|
||||
import os
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||
from py_scripts.utils import (
|
||||
process_preview_util, optimize_route_entries_util
|
||||
)
|
||||
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||
from py_scripts.blender_animator import BlenderGPSAnimator
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
from kivy.uix.textinput import TextInput
|
||||
from config import RESOURCES_FOLDER
|
||||
|
||||
class CreateAnimationScreen(Screen):
|
||||
project_name = StringProperty("")
|
||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||
preview_image_path = StringProperty("") # Add this line
|
||||
preview_image_version = NumericProperty(0) # Add this line
|
||||
|
||||
def get_preview_image_source(self):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
img_path = os.path.join(project_folder, "preview.png")
|
||||
if os.path.exists(img_path):
|
||||
return img_path
|
||||
return "resources/images/track.png"
|
||||
|
||||
preview_image_source = AliasProperty(
|
||||
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||
)
|
||||
|
||||
def on_pre_enter(self):
|
||||
# Update the route entries label with the actual number of entries
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
count = 0
|
||||
if os.path.exists(positions_path):
|
||||
with open(positions_path, "r") as f:
|
||||
try:
|
||||
positions = json.load(f)
|
||||
count = len(positions)
|
||||
except Exception:
|
||||
count = 0
|
||||
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||
|
||||
def open_rename_popup(self):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Enter new project name:")
|
||||
input_field = TextInput(text=self.project_name, multiline=False)
|
||||
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(input_field)
|
||||
layout.add_widget(btn_save)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
popup = Popup(
|
||||
title="Rename Project",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def do_rename(instance):
|
||||
new_name = input_field.text.strip()
|
||||
if new_name and new_name != self.project_name:
|
||||
if self.rename_project_folder(self.project_name, new_name):
|
||||
self.project_name = new_name
|
||||
popup.dismiss()
|
||||
self.on_pre_enter() # Refresh label
|
||||
else:
|
||||
label.text = "Rename failed (name exists?)"
|
||||
else:
|
||||
label.text = "Please enter a new name."
|
||||
|
||||
btn_save.bind(on_press=do_rename)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def rename_project_folder(self, old_name, new_name):
|
||||
import os
|
||||
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||
os.rename(old_path, new_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def optimize_route_entries(self):
|
||||
# Create the popup and UI elements
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route entries...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Optimizing Route",
|
||||
content=layout,
|
||||
size_hint=(0.92, None),
|
||||
size=(0, 260),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
# Now call the utility function with these objects
|
||||
optimize_route_entries_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
Clock,
|
||||
on_save=lambda: self.on_pre_enter()
|
||||
)
|
||||
|
||||
def preview_route(self):
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Processing route preview...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Previewing Route",
|
||||
content=layout,
|
||||
size_hint=(0.8, None),
|
||||
size=(0, 180),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def set_preview_image_path(path):
|
||||
self.preview_image_path = path
|
||||
self.preview_image_version += 1 # Force AliasProperty to update
|
||||
self.property('preview_image_source').dispatch(self)
|
||||
self.ids.preview_image.reload()
|
||||
# Schedule the processing function
|
||||
Clock.schedule_once(
|
||||
lambda dt: process_preview_util(
|
||||
self.project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
self.ids.preview_image,
|
||||
set_preview_image_path,
|
||||
Clock
|
||||
),
|
||||
0.5
|
||||
)
|
||||
|
||||
def open_pauses_popup(self):
|
||||
"""Navigate to the pause edit screen"""
|
||||
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||
self.manager.current = "pause_edit"
|
||||
|
||||
def generate_google_earth_animation(self):
|
||||
"""Generate Google Earth-style flythrough animation using NavigationAnimationGenerator"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing Google Earth flythrough...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Google Earth Flythrough",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_google_earth_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Loading GPS data...")
|
||||
|
||||
# Check dependencies first
|
||||
generator = NavigationAnimationGenerator(project_folder)
|
||||
generator.check_dependencies()
|
||||
|
||||
update_status(20, "Processing GPS coordinates...")
|
||||
df = generator.load_gps_data(positions_path)
|
||||
|
||||
update_status(40, "Creating Google Earth flythrough...")
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_google_earth_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the generator
|
||||
def generator_progress(progress, message):
|
||||
update_status(40 + (progress * 0.5), message) # Map 0-100% to 40-90%
|
||||
|
||||
update_status(90, "Creating flythrough video...")
|
||||
success = generator.generate_frames(positions_path, style='google_earth', progress_callback=generator_progress)
|
||||
|
||||
if success and len(success) > 0:
|
||||
update_status(95, "Rendering final video...")
|
||||
video_success = generator.create_video(success, output_video_path, generator_progress)
|
||||
if video_success:
|
||||
update_status(100, "Google Earth flythrough complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to create video from frames")
|
||||
else:
|
||||
raise Exception("Failed to generate frames")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Google Earth Flythrough Complete!",
|
||||
f"Your Google Earth-style flythrough has been saved to:\n{output_path}",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||
|
||||
def generate_blender_animation(self):
|
||||
"""Generate cinema-quality animation using Blender"""
|
||||
# Show processing popup
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text="Initializing Blender rendering pipeline...")
|
||||
progress = ProgressBar(max=100, value=0)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(progress)
|
||||
popup = Popup(
|
||||
title="Generating Blender Cinema Animation",
|
||||
content=layout,
|
||||
size_hint=(0.9, None),
|
||||
size=(0, 200),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def run_blender_animation():
|
||||
try:
|
||||
# Update status
|
||||
def update_status(progress_val, status_text):
|
||||
def _update(dt):
|
||||
progress.value = progress_val
|
||||
label.text = status_text
|
||||
Clock.schedule_once(_update, 0)
|
||||
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
update_status(0, "Error: No GPS data found")
|
||||
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||
return
|
||||
|
||||
update_status(10, "Loading GPS data into Blender...")
|
||||
|
||||
# Check dependencies first
|
||||
animator = BlenderGPSAnimator(project_folder)
|
||||
animator.check_dependencies()
|
||||
|
||||
update_status(25, "Processing GPS coordinates...")
|
||||
gps_data = animator.load_gps_data(positions_path)
|
||||
|
||||
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||
|
||||
# Progress callback for the animator
|
||||
def animator_progress(progress, message):
|
||||
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||
|
||||
update_status(85, "Rendering cinema-quality video...")
|
||||
success = animator.create_gps_animation(
|
||||
positions_path,
|
||||
output_video_path,
|
||||
progress_callback=animator_progress
|
||||
)
|
||||
|
||||
if success:
|
||||
update_status(100, "Blender cinema animation complete!")
|
||||
output_path = output_video_path
|
||||
else:
|
||||
raise Exception("Failed to generate Blender animation")
|
||||
|
||||
def show_success(dt):
|
||||
popup.dismiss()
|
||||
self.show_success_popup(
|
||||
"Blender Cinema Animation Complete!",
|
||||
f"Your cinema-quality animation has been saved to:\n{output_path}",
|
||||
output_path
|
||||
)
|
||||
|
||||
Clock.schedule_once(show_success, 1)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
def show_error(dt):
|
||||
popup.dismiss()
|
||||
self.show_error_popup("Blender Animation Error", error_message)
|
||||
|
||||
Clock.schedule_once(show_error, 0)
|
||||
|
||||
# Schedule the animation generation
|
||||
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||
|
||||
def show_success_popup(self, title, message, file_path):
|
||||
"""Show success popup with option to open file location"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||
|
||||
# Success message
|
||||
success_label = Label(
|
||||
text=message,
|
||||
text_size=(None, None),
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
layout.add_widget(success_label)
|
||||
|
||||
# Buttons
|
||||
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||
|
||||
open_folder_btn = Button(
|
||||
text="Open Folder",
|
||||
background_color=(0.2, 0.6, 0.9, 1)
|
||||
)
|
||||
|
||||
ok_btn = Button(
|
||||
text="OK",
|
||||
background_color=(0.3, 0.7, 0.3, 1)
|
||||
)
|
||||
|
||||
btn_layout.add_widget(open_folder_btn)
|
||||
btn_layout.add_widget(ok_btn)
|
||||
layout.add_widget(btn_layout)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.9, 0.6),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def open_folder(instance):
|
||||
folder_path = os.path.dirname(file_path)
|
||||
os.system(f'xdg-open "{folder_path}"') # Linux
|
||||
popup.dismiss()
|
||||
|
||||
def close_popup(instance):
|
||||
popup.dismiss()
|
||||
|
||||
open_folder_btn.bind(on_press=open_folder)
|
||||
ok_btn.bind(on_press=close_popup)
|
||||
|
||||
popup.open()
|
||||
|
||||
def show_error_popup(self, title, message):
|
||||
"""Show error popup"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||
|
||||
error_label = Label(
|
||||
text=f"Error: {message}",
|
||||
text_size=(None, None),
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
layout.add_widget(error_label)
|
||||
|
||||
ok_btn = Button(
|
||||
text="OK",
|
||||
background_color=(0.8, 0.3, 0.3, 1),
|
||||
size_hint_y=None,
|
||||
height=50
|
||||
)
|
||||
layout.add_widget(ok_btn)
|
||||
|
||||
popup = Popup(
|
||||
title=title,
|
||||
content=layout,
|
||||
size_hint=(0.8, 0.4),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
347
screens/get_trip_from_server.py
Normal file
347
screens/get_trip_from_server.py
Normal file
@@ -0,0 +1,347 @@
|
||||
import kivy
|
||||
from kivy.app import App
|
||||
from kivy.uix.screenmanager import ScreenManager, Screen
|
||||
import os
|
||||
import json
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, ListProperty
|
||||
from py_scripts.utils import (
|
||||
generate_key, load_key, encrypt_data, decrypt_data,
|
||||
check_server_settings, save_server_settings,
|
||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||
)
|
||||
from datetime import date
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from threading import Thread
|
||||
from kivy.clock import mainthread
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
import webbrowser
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from PIL import Image
|
||||
import time
|
||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||
|
||||
class GetTripFromServer(Screen):
|
||||
server_info_text = StringProperty("LOADING DATA...")
|
||||
server_box_color = ListProperty([0.984, 0.553, 0.078, 1])
|
||||
device_mapping = {}
|
||||
|
||||
def on_pre_enter(self):
|
||||
self.server_box_color = [0.984, 0.553, 0.078, 1]
|
||||
self.server_info_text = "LOADING DATA..."
|
||||
Clock.schedule_once(self.check_server_settings, 1)
|
||||
|
||||
def check_server_settings(self, dt):
|
||||
settings = check_server_settings()
|
||||
if settings:
|
||||
server_url = settings["server_url"]
|
||||
username = settings["username"]
|
||||
password = settings["password"]
|
||||
token = settings["token"]
|
||||
self.server_info_text = f"CHECKING server: {server_url}"
|
||||
self.server_box_color = [0.984, 0.553, 0.078, 1]
|
||||
self.ids.devices_spinner.text = "Loading devices..."
|
||||
Clock.schedule_once(lambda dt: self.test_connection(server_url, username, password, token), 1)
|
||||
else:
|
||||
self.server_info_text = "Go to settings and set Traccar Server"
|
||||
self.server_box_color = [0.909, 0.031, 0.243, 1]
|
||||
self.ids.devices_spinner.text = "No devices available"
|
||||
|
||||
def set_result_message(self, message, color=(1, 1, 1, 1)):
|
||||
self.ids.result_label.text = message
|
||||
self.ids.result_label.color = color
|
||||
|
||||
def update_devices_spinner(self, devices):
|
||||
if devices:
|
||||
self.device_mapping = devices
|
||||
device_names = list(devices.keys())
|
||||
self.ids.devices_spinner.values = device_names
|
||||
self.ids.devices_spinner.text = "Select a device"
|
||||
else:
|
||||
self.ids.devices_spinner.text = "No devices found"
|
||||
self.ids.devices_spinner.values = []
|
||||
|
||||
def test_connection(self, server_url, username, password, token):
|
||||
result = test_connection(server_url, username, password, token)
|
||||
if result["status"]:
|
||||
self.server_info_text = f"Connected to {server_url}"
|
||||
self.server_box_color = [0.008, 0.525, 0.290, 1]
|
||||
devices = get_devices_from_server()
|
||||
self.update_devices_spinner(devices)
|
||||
else:
|
||||
self.server_info_text = result["message"]
|
||||
self.server_box_color = [0.909, 0.031, 0.243, 1]
|
||||
|
||||
def on_device_selected(self, device_name):
|
||||
if device_name != "Loading devices..." and device_name != "No devices found":
|
||||
self.ids.devices_spinner.background_color = (0.008, 0.525, 0.290, 1)
|
||||
else:
|
||||
self.ids.devices_spinner.background_color = (1, 1, 1, 1)
|
||||
|
||||
def open_date_picker(self, which):
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.popup import Popup
|
||||
|
||||
import calendar
|
||||
today = date.today()
|
||||
selected = {"year": today.year, "month": today.month}
|
||||
|
||||
def update_grid():
|
||||
grid.clear_widgets()
|
||||
days_in_month = calendar.monthrange(selected["year"], selected["month"])[1]
|
||||
for day in range(1, days_in_month + 1):
|
||||
btn = Button(
|
||||
text=str(day),
|
||||
size_hint=(None, None),
|
||||
size=(38, 38),
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=15
|
||||
)
|
||||
def on_date_selected(instance, day=day):
|
||||
date_str = f"{selected['year']}-{selected['month']:02d}-{day:02d}"
|
||||
if which == 'start':
|
||||
self.ids.start_date_picker_button.text = date_str
|
||||
else:
|
||||
self.ids.end_date_picker_button.text = date_str
|
||||
popup.dismiss()
|
||||
btn.bind(on_press=on_date_selected)
|
||||
grid.add_widget(btn)
|
||||
|
||||
def prev_month(instance):
|
||||
if selected["month"] > 1:
|
||||
selected["month"] -= 1
|
||||
else:
|
||||
selected["year"] -= 1
|
||||
selected["month"] = 12
|
||||
# Don't allow future months
|
||||
if (selected["year"], selected["month"]) > (today.year, today.month):
|
||||
selected["year"], selected["month"] = today.year, today.month
|
||||
title_label.text = f"Select a Date ({selected['year']}-{selected['month']:02d})"
|
||||
update_grid()
|
||||
|
||||
def next_month(instance):
|
||||
# Only allow up to current month
|
||||
if (selected["year"], selected["month"]) < (today.year, today.month):
|
||||
if selected["month"] < 12:
|
||||
selected["month"] += 1
|
||||
else:
|
||||
selected["year"] += 1
|
||||
selected["month"] = 1
|
||||
title_label.text = f"Select a Date ({selected['year']}-{selected['month']:02d})"
|
||||
update_grid()
|
||||
|
||||
def on_cancel(instance):
|
||||
popup.dismiss()
|
||||
|
||||
# Main vertical layout
|
||||
main_layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
# Month navigation row
|
||||
nav_layout = BoxLayout(orientation='horizontal', size_hint_y=None, height=40, spacing=10)
|
||||
prev_btn = Button(text="<", size_hint_x=None, width=40, font_size=18, background_color=(0.341, 0.235, 0.980, 1), color=(1,1,1,1))
|
||||
next_btn = Button(text=">", size_hint_x=None, width=40, font_size=18, background_color=(0.341, 0.235, 0.980, 1), color=(1,1,1,1))
|
||||
title_label = Label(
|
||||
text=f"Select a Date ({selected['year']}-{selected['month']:02d})",
|
||||
size_hint_x=1,
|
||||
font_size=18,
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
prev_btn.bind(on_press=prev_month)
|
||||
next_btn.bind(on_press=next_month)
|
||||
nav_layout.add_widget(prev_btn)
|
||||
nav_layout.add_widget(title_label)
|
||||
nav_layout.add_widget(next_btn)
|
||||
main_layout.add_widget(nav_layout)
|
||||
|
||||
# Grid of days: 6 columns
|
||||
grid = GridLayout(cols=6, spacing=6, size_hint_y=None)
|
||||
grid.bind(minimum_height=grid.setter('height'))
|
||||
main_layout.add_widget(grid)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
background_color=(0.909, 0.031, 0.243, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16
|
||||
)
|
||||
cancel_btn.bind(on_press=on_cancel)
|
||||
main_layout.add_widget(cancel_btn)
|
||||
|
||||
popup = Popup(
|
||||
title="",
|
||||
content=main_layout,
|
||||
size_hint=(0.95, 0.8),
|
||||
background_color=(0.11, 0.10, 0.15, 1),
|
||||
separator_height=0,
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
update_grid()
|
||||
popup.open()
|
||||
|
||||
def open_hour_picker(self, which):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
|
||||
popup_layout = BoxLayout(orientation='vertical', spacing=8, padding=8)
|
||||
scroll = ScrollView(size_hint=(1, 1))
|
||||
grid = GridLayout(cols=1, size_hint_y=None, spacing=4)
|
||||
grid.bind(minimum_height=grid.setter('height'))
|
||||
|
||||
# Add hour buttons
|
||||
for h in range(24):
|
||||
hour_str = f"{h:02d}"
|
||||
btn = Button(
|
||||
text=hour_str,
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
font_size=18,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
def set_hour(instance, hour=hour_str):
|
||||
if which == 'start':
|
||||
self.ids.start_hour_button.text = hour
|
||||
else:
|
||||
self.ids.end_hour_button.text = hour
|
||||
popup.dismiss()
|
||||
btn.bind(on_press=set_hour)
|
||||
grid.add_widget(btn)
|
||||
|
||||
scroll.add_widget(grid)
|
||||
popup_layout.add_widget(scroll)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
background_color=(0.909, 0.031, 0.243, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16
|
||||
)
|
||||
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup_layout.add_widget(cancel_btn)
|
||||
|
||||
popup = Popup(
|
||||
title="Select Hour",
|
||||
content=popup_layout,
|
||||
size_hint=(0.6, 0.7),
|
||||
auto_dismiss=False
|
||||
)
|
||||
popup.open()
|
||||
|
||||
def update_points_count(self, count):
|
||||
"""Update the label showing the number of points."""
|
||||
self.ids.points_count_label.text = f"Points: {count}"
|
||||
|
||||
def save_route(self):
|
||||
"""Save the current list of positions as a route in resources/projects/<route_name>/positions.json."""
|
||||
route_name = self.ids.route_name_input.text.strip()
|
||||
positions = getattr(self, "last_positions", None)
|
||||
|
||||
success, message, file_path = save_route_to_file(route_name, positions)
|
||||
self.ids.result_label.text = message
|
||||
|
||||
if success:
|
||||
# Reset UI fields
|
||||
self.ids.devices_spinner.text = "Select a device"
|
||||
self.ids.start_date_picker_button.text = "Select Start Date"
|
||||
self.ids.end_date_picker_button.text = "Select End Date"
|
||||
self.ids.start_hour_button.text = "00"
|
||||
self.ids.end_hour_button.text = "23"
|
||||
self.ids.points_count_label.text = "Points: 0"
|
||||
self.ids.route_name_input.text = ""
|
||||
|
||||
# Show popup and schedule reroute to home
|
||||
popup = Popup(title="Success",
|
||||
content=Label(text=f"Route '{route_name}' saved!"),
|
||||
size_hint=(None, None), size=(300, 150),
|
||||
auto_dismiss=False)
|
||||
popup.open()
|
||||
def close_and_go_home(dt):
|
||||
popup.dismiss()
|
||||
self.manager.current = "home"
|
||||
Clock.schedule_once(close_and_go_home, 3)
|
||||
|
||||
def get_trip_server_data(self):
|
||||
"""Handle the Get trip server data button press."""
|
||||
selected_device = self.ids.devices_spinner.text
|
||||
start_date = self.ids.start_date_picker_button.text
|
||||
end_date = self.ids.end_date_picker_button.text
|
||||
|
||||
if selected_device == "Loading devices..." or selected_device == "No devices found":
|
||||
print("No valid device selected.")
|
||||
self.ids.result_label.text = "Please select a valid device."
|
||||
return
|
||||
|
||||
if start_date == "Select Date" or end_date == "Select Date":
|
||||
print("No valid date selected.")
|
||||
self.ids.result_label.text = "Please select valid start and end dates."
|
||||
return
|
||||
|
||||
# Fetch trip data from the server
|
||||
print(f"Fetching trip data for device: {selected_device} from {start_date} to {end_date}")
|
||||
self.ids.result_label.text = f"Fetching trip data for {selected_device} from {start_date} to {end_date}..."
|
||||
|
||||
positions = self.fetch_positions_for_selected_day()
|
||||
self.last_positions = positions # Store for saving
|
||||
self.update_points_count(len(positions) if positions else 0)
|
||||
if positions:
|
||||
print("Positions received:")
|
||||
for pos in positions:
|
||||
print(f"{pos['deviceTime']}: {pos['latitude']}, {pos['longitude']}")
|
||||
else:
|
||||
print("No positions found or error occurred.")
|
||||
|
||||
def fetch_positions_for_selected_day(self):
|
||||
settings = check_server_settings()
|
||||
device_name = self.ids.devices_spinner.text
|
||||
start_date = self.ids.start_date_picker_button.text
|
||||
end_date = self.ids.end_date_picker_button.text
|
||||
start_hour = self.ids.start_hour_button.text
|
||||
end_hour = self.ids.end_hour_button.text
|
||||
|
||||
positions, error = fetch_positions_for_selected_day(
|
||||
settings,
|
||||
self.device_mapping,
|
||||
device_name,
|
||||
start_date,
|
||||
end_date,
|
||||
start_hour,
|
||||
end_hour
|
||||
)
|
||||
if error:
|
||||
self.ids.result_label.text = error
|
||||
return []
|
||||
self.ids.result_label.text = f"Retrieved {len(positions)} positions."
|
||||
return positions
|
||||
|
||||
def fetch_devices_async(self):
|
||||
Thread(target=self._fetch_devices_worker).start()
|
||||
|
||||
def _fetch_devices_worker(self):
|
||||
devices = get_devices_from_server()
|
||||
self.update_devices_spinner_mainthread(devices)
|
||||
|
||||
@mainthread
|
||||
def update_devices_spinner_mainthread(self, devices):
|
||||
self.update_devices_spinner(devices)
|
||||
|
||||
152
screens/home_screen.py
Normal file
152
screens/home_screen.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.clock import Clock
|
||||
import os
|
||||
import json
|
||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||
from py_scripts.utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
||||
|
||||
|
||||
class IconButton(ButtonBehavior, Image):
|
||||
pass
|
||||
|
||||
|
||||
class HomeScreen(Screen):
|
||||
def on_pre_enter(self):
|
||||
"""Load existing projects/trips when the screen is entered."""
|
||||
self.load_existing_projects()
|
||||
|
||||
def load_existing_projects(self):
|
||||
projects_folder = os.path.join(RESOURCES_FOLDER, "projects")
|
||||
archive_folder = os.path.join(RESOURCES_FOLDER, "trip_archive")
|
||||
if not os.path.exists(projects_folder):
|
||||
os.makedirs(projects_folder)
|
||||
if not os.path.exists(archive_folder):
|
||||
os.makedirs(archive_folder)
|
||||
|
||||
self.ids.projects_list.clear_widgets()
|
||||
|
||||
for project in os.listdir(projects_folder):
|
||||
row = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint_y=None,
|
||||
height=44,
|
||||
spacing=6,
|
||||
padding=(8, 6)
|
||||
)
|
||||
from kivy.graphics import Color, Line, RoundedRectangle
|
||||
with row.canvas.before:
|
||||
Color(0.11, 0.10, 0.15, 1)
|
||||
row.bg_rect = RoundedRectangle(pos=row.pos, size=row.size, radius=[6])
|
||||
Color(0.341, 0.235, 0.980, 1)
|
||||
row.border_line = Line(rounded_rectangle=[row.x, row.y, row.width, row.height, 6], width=1)
|
||||
# Use a closure to bind the correct row instance
|
||||
def make_update_bg_rect(r):
|
||||
def update_bg_rect(instance, value):
|
||||
r.bg_rect.pos = r.pos
|
||||
r.bg_rect.size = r.size
|
||||
r.border_line.rounded_rectangle = [r.x, r.y, r.width, r.height, 6]
|
||||
return update_bg_rect
|
||||
row.bind(pos=make_update_bg_rect(row), size=make_update_bg_rect(row))
|
||||
|
||||
project_label = Label(
|
||||
text=project,
|
||||
size_hint_x=0.64,
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=15,
|
||||
shorten=True,
|
||||
shorten_from='right'
|
||||
)
|
||||
|
||||
edit_button = IconButton(
|
||||
source="resources/images/edit.png",
|
||||
size_hint_x=0.18,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
edit_button.bind(on_press=lambda instance, p=project: self.edit_project(p))
|
||||
|
||||
delete_button = IconButton(
|
||||
source="resources/images/delete.png",
|
||||
size_hint_x=0.18,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
delete_button.bind(on_press=lambda instance, p=project: self.confirm_delete_project(p))
|
||||
|
||||
row.add_widget(project_label)
|
||||
row.add_widget(edit_button)
|
||||
row.add_widget(delete_button)
|
||||
self.ids.projects_list.add_widget(row)
|
||||
|
||||
def open_project(self, project_name):
|
||||
"""Handle opening an existing project/trip."""
|
||||
print(f"Opening project: {project_name}")
|
||||
self.ids.result_label.text = f"Opened project: {project_name}"
|
||||
|
||||
def create_new_project(self):
|
||||
"""Navigate to the GetTripFromServer screen to create a new project/trip."""
|
||||
self.manager.current = "get_trip_from_server"
|
||||
|
||||
def edit_project(self, project_name):
|
||||
# Set the project name on the CreateAnimationScreen before switching
|
||||
create_anim_screen = self.manager.get_screen("create_animation")
|
||||
create_anim_screen.project_name = project_name
|
||||
self.manager.current = "create_animation"
|
||||
|
||||
# delete or archive project
|
||||
def confirm_delete_project(self, project_name):
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
label = Label(text=f"Delete project '{project_name}'?\nChoose an option:")
|
||||
btn_delete = Button(text="Delete Completely", background_color=(1, 0, 0, 1))
|
||||
btn_archive = Button(text="Archive Trip", background_color=(0.341, 0.235, 0.980, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
|
||||
popup = Popup(title="Delete Project", content=layout, size_hint=(None, None), size=(400, 250), auto_dismiss=False)
|
||||
layout.add_widget(label)
|
||||
layout.add_widget(btn_delete)
|
||||
layout.add_widget(btn_archive)
|
||||
layout.add_widget(btn_cancel)
|
||||
|
||||
def do_delete(instance):
|
||||
self.delete_project(project_name)
|
||||
popup.dismiss()
|
||||
self.load_existing_projects()
|
||||
|
||||
def do_archive(instance):
|
||||
self.archive_project(project_name)
|
||||
popup.dismiss()
|
||||
self.load_existing_projects()
|
||||
|
||||
btn_delete.bind(on_press=do_delete)
|
||||
btn_archive.bind(on_press=do_archive)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def delete_project(self, project_name):
|
||||
import shutil
|
||||
folder_path = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
if os.path.exists(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
|
||||
def archive_project(self, project_name):
|
||||
import shutil
|
||||
src_file = os.path.join(RESOURCES_FOLDER, "projects", project_name, "positions.json")
|
||||
archive_folder = os.path.join(RESOURCES_FOLDER, "trip_archive")
|
||||
if not os.path.exists(archive_folder):
|
||||
os.makedirs(archive_folder)
|
||||
dst_file = os.path.join(archive_folder, f"{project_name}.json")
|
||||
if os.path.exists(src_file):
|
||||
shutil.copy2(src_file, dst_file)
|
||||
# Optionally, delete the project folder after archiving
|
||||
self.delete_project(project_name)
|
||||
29
screens/login_screen.py
Normal file
29
screens/login_screen.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.clock import Clock
|
||||
import os
|
||||
import json
|
||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||
from py_scripts.utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
||||
|
||||
|
||||
class LoginScreen(Screen):
|
||||
def login(self):
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.username_input.text.strip()
|
||||
|
||||
if not username or not password:
|
||||
self.manager.get_screen("home").ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
|
||||
credentials = {"username": username, "password": password}
|
||||
encrypted_data = encrypt_data(json.dumps(credentials))
|
||||
with open(CREDENTIALS_FILE, "wb") as file:
|
||||
file.write(encrypted_data)
|
||||
|
||||
self.manager.current = "home"
|
||||
0
screens/pause_edit_screen.py
Normal file
0
screens/pause_edit_screen.py
Normal file
889
screens/pause_edit_screen_improved.py
Normal file
889
screens/pause_edit_screen_improved.py
Normal file
@@ -0,0 +1,889 @@
|
||||
import kivy
|
||||
from kivy.uix.screenmanager import Screen
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.filechooser import FileChooserIconView
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.carousel import Carousel
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
from kivy.graphics import Color, Rectangle, Line
|
||||
from kivy.uix.scrollview import ScrollView
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.properties import StringProperty
|
||||
from kivy.clock import Clock
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
from geopy.geocoders import Nominatim
|
||||
from config import RESOURCES_FOLDER
|
||||
|
||||
class PauseEditScreen(Screen):
|
||||
project_name = StringProperty("")
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.pauses = []
|
||||
self.on_save_callback = None
|
||||
self.loading_popup = None
|
||||
self.carousel = None
|
||||
|
||||
def on_pre_enter(self):
|
||||
"""Called when entering the screen"""
|
||||
self.show_loading_popup()
|
||||
# Delay the layout building to show loading popup first
|
||||
Clock.schedule_once(self.start_loading_process, 0.1)
|
||||
|
||||
def show_loading_popup(self):
|
||||
"""Show loading popup while building the layout"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=20, padding=20)
|
||||
|
||||
# Loading animation/progress bar
|
||||
progress = ProgressBar(
|
||||
max=100,
|
||||
size_hint_y=None,
|
||||
height=20
|
||||
)
|
||||
|
||||
# Animate the progress bar
|
||||
def animate_progress(dt):
|
||||
if progress.value < 95:
|
||||
progress.value += 5
|
||||
else:
|
||||
progress.value = 10 # Reset for continuous animation
|
||||
|
||||
Clock.schedule_interval(animate_progress, 0.1)
|
||||
|
||||
loading_label = Label(
|
||||
text="Loading pause information...\nPlease wait",
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16,
|
||||
halign="center",
|
||||
text_size=(300, None)
|
||||
)
|
||||
|
||||
layout.add_widget(loading_label)
|
||||
layout.add_widget(progress)
|
||||
|
||||
self.loading_popup = Popup(
|
||||
title="Loading Pauses",
|
||||
content=layout,
|
||||
size_hint=(0.8, 0.3),
|
||||
auto_dismiss=False
|
||||
)
|
||||
self.loading_popup.open()
|
||||
|
||||
def start_loading_process(self, dt):
|
||||
"""Start the loading process in background"""
|
||||
# Run the heavy loading in a separate thread
|
||||
thread = threading.Thread(target=self.load_data_background)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def load_data_background(self):
|
||||
"""Load pause data in background thread"""
|
||||
try:
|
||||
# Load pauses
|
||||
self.load_pauses()
|
||||
|
||||
# Pre-process location suggestions to speed up UI
|
||||
for pause in self.pauses:
|
||||
lat = pause["location"]["latitude"]
|
||||
lon = pause["location"]["longitude"]
|
||||
# Cache the location suggestion
|
||||
if 'location_suggestion' not in pause:
|
||||
pause['location_suggestion'] = self.suggest_location_name(lat, lon)
|
||||
|
||||
# Schedule UI update on main thread
|
||||
Clock.schedule_once(self.finish_loading, 0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading pause data: {e}")
|
||||
Clock.schedule_once(self.finish_loading, 0)
|
||||
|
||||
def finish_loading(self, dt):
|
||||
"""Finish loading and build the UI"""
|
||||
try:
|
||||
self.build_pause_layout()
|
||||
finally:
|
||||
# Close loading popup
|
||||
if self.loading_popup:
|
||||
self.loading_popup.dismiss()
|
||||
self.loading_popup = None
|
||||
|
||||
def suggest_location_name(self, lat, lon):
|
||||
"""Simplified location suggestion"""
|
||||
try:
|
||||
geolocator = Nominatim(user_agent="traccar_animation")
|
||||
location = geolocator.reverse((lat, lon), exactly_one=True, timeout=8, addressdetails=True)
|
||||
|
||||
if location and location.raw:
|
||||
address = location.raw.get('address', {})
|
||||
|
||||
# Look for street address first
|
||||
if 'road' in address:
|
||||
road = address['road']
|
||||
if 'house_number' in address:
|
||||
return f"{road} {address['house_number']}"
|
||||
return road
|
||||
|
||||
# Look for area name
|
||||
for key in ['neighbourhood', 'suburb', 'village', 'town', 'city']:
|
||||
if key in address and address[key]:
|
||||
return address[key]
|
||||
|
||||
return f"Location {lat:.4f}, {lon:.4f}"
|
||||
|
||||
except Exception:
|
||||
return f"Location {lat:.4f}, {lon:.4f}"
|
||||
|
||||
def load_pauses(self):
|
||||
"""Load pauses from the project folder"""
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
|
||||
if os.path.exists(pauses_path):
|
||||
with open(pauses_path, "r") as f:
|
||||
self.pauses = json.load(f)
|
||||
else:
|
||||
self.pauses = []
|
||||
|
||||
def save_pauses(self):
|
||||
"""Save pauses to the project folder"""
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
|
||||
with open(pauses_path, "w") as f:
|
||||
json.dump(self.pauses, f, indent=2)
|
||||
|
||||
def build_pause_layout(self):
|
||||
"""Build the main pause editing layout with carousel for multiple pauses"""
|
||||
self.clear_widgets()
|
||||
|
||||
# Main layout with dark background
|
||||
main_layout = BoxLayout(orientation='vertical', spacing=5, padding=[5, 5, 5, 5])
|
||||
with main_layout.canvas.before:
|
||||
Color(0.11, 0.10, 0.15, 1)
|
||||
main_layout.bg_rect = Rectangle(pos=main_layout.pos, size=main_layout.size)
|
||||
main_layout.bind(pos=self.update_bg_rect, size=self.update_bg_rect)
|
||||
|
||||
# Header with back button and pause counter
|
||||
header = BoxLayout(orientation='horizontal', size_hint_y=None, height=40, spacing=10)
|
||||
|
||||
back_btn = Button(
|
||||
text="← Back",
|
||||
size_hint_x=None,
|
||||
width=70,
|
||||
font_size=14,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
back_btn.bind(on_press=self.go_back)
|
||||
|
||||
# Dynamic title based on number of pauses
|
||||
pause_count = len(self.pauses)
|
||||
if pause_count > 2:
|
||||
title_text = f"Edit Pauses ({pause_count} total)\nSwipe to navigate"
|
||||
else:
|
||||
title_text = "Edit Pauses"
|
||||
|
||||
title_label = Label(
|
||||
text=title_text,
|
||||
font_size=14 if pause_count > 2 else 16,
|
||||
color=(1, 1, 1, 1),
|
||||
halign="center",
|
||||
bold=True
|
||||
)
|
||||
|
||||
header.add_widget(back_btn)
|
||||
header.add_widget(title_label)
|
||||
header.add_widget(Widget(size_hint_x=None, width=70))
|
||||
|
||||
main_layout.add_widget(header)
|
||||
|
||||
# Choose layout based on number of pauses
|
||||
if pause_count > 2:
|
||||
# Use carousel for multiple pauses
|
||||
content_area = self.create_carousel_layout()
|
||||
else:
|
||||
# Use simple scroll view for 1-2 pauses
|
||||
content_area = self.create_simple_scroll_layout()
|
||||
|
||||
main_layout.add_widget(content_area)
|
||||
|
||||
# Save all button at bottom
|
||||
save_all_btn = Button(
|
||||
text="Save All Changes & Go Back",
|
||||
size_hint_y=None,
|
||||
height=45,
|
||||
font_size=14,
|
||||
background_color=(0.2, 0.7, 0.2, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
bold=True
|
||||
)
|
||||
save_all_btn.bind(on_press=self.save_all_and_close)
|
||||
main_layout.add_widget(save_all_btn)
|
||||
|
||||
self.add_widget(main_layout)
|
||||
|
||||
def create_carousel_layout(self):
|
||||
"""Create carousel layout for multiple pauses"""
|
||||
# Create carousel
|
||||
self.carousel = Carousel(
|
||||
direction='right',
|
||||
loop=True,
|
||||
size_hint=(1, 1)
|
||||
)
|
||||
|
||||
# Add each pause as a slide
|
||||
for idx, pause in enumerate(self.pauses):
|
||||
# Create a slide container
|
||||
slide = BoxLayout(orientation='vertical', spacing=5, padding=[5, 5, 5, 5])
|
||||
|
||||
# Add pause indicator
|
||||
indicator = Label(
|
||||
text=f"Pause {idx + 1} of {len(self.pauses)} - Swipe for more",
|
||||
font_size=12,
|
||||
color=(0.8, 0.8, 0.8, 1),
|
||||
size_hint_y=None,
|
||||
height=25,
|
||||
halign="center"
|
||||
)
|
||||
slide.add_widget(indicator)
|
||||
|
||||
# Create pause frame
|
||||
pause_frame = self.create_pause_frame(idx, pause)
|
||||
|
||||
# Wrap in scroll view for this slide
|
||||
scroll = ScrollView(size_hint=(1, 1))
|
||||
scroll_content = BoxLayout(orientation='vertical', size_hint_y=None, padding=[2, 2, 2, 2])
|
||||
scroll_content.bind(minimum_height=scroll_content.setter('height'))
|
||||
scroll_content.add_widget(pause_frame)
|
||||
scroll.add_widget(scroll_content)
|
||||
|
||||
slide.add_widget(scroll)
|
||||
self.carousel.add_widget(slide)
|
||||
|
||||
return self.carousel
|
||||
|
||||
def create_simple_scroll_layout(self):
|
||||
"""Create simple scroll layout for 1-2 pauses"""
|
||||
scroll_content = BoxLayout(orientation='vertical', spacing=10, size_hint_y=None, padding=[5, 5, 5, 5])
|
||||
scroll_content.bind(minimum_height=scroll_content.setter('height'))
|
||||
|
||||
for idx, pause in enumerate(self.pauses):
|
||||
pause_frame = self.create_pause_frame(idx, pause)
|
||||
scroll_content.add_widget(pause_frame)
|
||||
|
||||
scroll = ScrollView(size_hint=(1, 1))
|
||||
scroll.add_widget(scroll_content)
|
||||
return scroll
|
||||
|
||||
def update_bg_rect(self, instance, value):
|
||||
"""Update background rectangle"""
|
||||
instance.bg_rect.pos = instance.pos
|
||||
instance.bg_rect.size = instance.size
|
||||
|
||||
def create_pause_frame(self, idx, pause):
|
||||
"""Create a frame for a single pause"""
|
||||
# Main frame with border
|
||||
frame = BoxLayout(
|
||||
orientation='vertical',
|
||||
spacing=8,
|
||||
padding=[8, 8, 8, 8],
|
||||
size_hint_y=None,
|
||||
height=380 # Increased height for better photo scrolling
|
||||
)
|
||||
|
||||
with frame.canvas.before:
|
||||
Color(0.18, 0.18, 0.22, 1) # Frame background
|
||||
frame.bg_rect = Rectangle(pos=frame.pos, size=frame.size)
|
||||
Color(0.4, 0.6, 1.0, 1) # Frame border
|
||||
frame.border_line = Line(rectangle=(frame.x, frame.y, frame.width, frame.height), width=2)
|
||||
|
||||
def update_frame(instance, value, frame_widget=frame):
|
||||
frame_widget.bg_rect.pos = frame_widget.pos
|
||||
frame_widget.bg_rect.size = frame_widget.size
|
||||
frame_widget.border_line.rectangle = (frame_widget.x, frame_widget.y, frame_widget.width, frame_widget.height)
|
||||
frame.bind(pos=update_frame, size=update_frame)
|
||||
|
||||
# 1. Pause number label (centered)
|
||||
pause_number_label = Label(
|
||||
text=f"[b]PAUSE {idx + 1}[/b]",
|
||||
markup=True,
|
||||
font_size=16,
|
||||
color=(1, 1, 1, 1),
|
||||
size_hint_y=None,
|
||||
height=30,
|
||||
halign="center",
|
||||
valign="middle"
|
||||
)
|
||||
pause_number_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
frame.add_widget(pause_number_label)
|
||||
|
||||
# 2. Location suggestion (left aligned) - use cached version if available
|
||||
suggested_place = pause.get('location_suggestion') or self.suggest_location_name(
|
||||
pause["location"]["latitude"], pause["location"]["longitude"]
|
||||
)
|
||||
|
||||
location_label = Label(
|
||||
text=f"Location: {suggested_place}",
|
||||
font_size=12,
|
||||
color=(0.8, 0.9, 1, 1),
|
||||
size_hint_y=None,
|
||||
height=25,
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
location_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
frame.add_widget(location_label)
|
||||
|
||||
# 3. Custom name entry and save button
|
||||
name_layout = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=35)
|
||||
|
||||
name_input = TextInput(
|
||||
text=pause.get('name', ''),
|
||||
hint_text="Enter custom location name...",
|
||||
multiline=False,
|
||||
background_color=(0.25, 0.25, 0.3, 1),
|
||||
foreground_color=(1, 1, 1, 1),
|
||||
font_size=12,
|
||||
padding=[8, 8, 8, 8]
|
||||
)
|
||||
|
||||
save_name_btn = Button(
|
||||
text="Save Name",
|
||||
size_hint_x=None,
|
||||
width=80,
|
||||
font_size=11,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
save_name_btn.bind(on_press=lambda x: self.save_pause_name(pause, name_input))
|
||||
|
||||
name_layout.add_widget(name_input)
|
||||
name_layout.add_widget(save_name_btn)
|
||||
frame.add_widget(name_layout)
|
||||
|
||||
# 4. Photos area - vertical scrolling
|
||||
photos_area = self.create_photos_area_vertical(idx, pause)
|
||||
frame.add_widget(photos_area)
|
||||
|
||||
# 5. Save and Delete buttons row
|
||||
button_layout = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=30)
|
||||
|
||||
save_pause_btn = Button(
|
||||
text="Save Pause Info",
|
||||
font_size=12,
|
||||
background_color=(0.2, 0.6, 0.8, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
save_pause_btn.bind(on_press=lambda x: self.save_individual_pause(idx))
|
||||
|
||||
delete_pause_btn = Button(
|
||||
text="Delete Pause",
|
||||
font_size=12,
|
||||
background_color=(0.8, 0.2, 0.2, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
delete_pause_btn.bind(on_press=lambda x: self.delete_pause(idx))
|
||||
|
||||
button_layout.add_widget(save_pause_btn)
|
||||
button_layout.add_widget(delete_pause_btn)
|
||||
frame.add_widget(button_layout)
|
||||
|
||||
return frame
|
||||
|
||||
def create_photos_area_vertical(self, pause_idx, pause):
|
||||
"""Create photos area with vertical scrolling"""
|
||||
photos_layout = BoxLayout(orientation='vertical', spacing=5, size_hint_y=None, height=200)
|
||||
|
||||
# Photos header with add button
|
||||
photos_header = BoxLayout(orientation='horizontal', size_hint_y=None, height=30)
|
||||
|
||||
photos_title = Label(
|
||||
text="Photos:",
|
||||
font_size=12,
|
||||
color=(1, 1, 1, 1),
|
||||
size_hint_x=0.5,
|
||||
halign="left",
|
||||
valign="middle"
|
||||
)
|
||||
photos_title.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
|
||||
add_photos_btn = Button(
|
||||
text="Add Photos",
|
||||
size_hint_x=0.5,
|
||||
font_size=11,
|
||||
background_color=(0.2, 0.7, 0.2, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
add_photos_btn.bind(on_press=lambda x: self.add_photos(pause_idx))
|
||||
|
||||
photos_header.add_widget(photos_title)
|
||||
photos_header.add_widget(add_photos_btn)
|
||||
photos_layout.add_widget(photos_header)
|
||||
|
||||
# Get photos for this pause
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||
os.makedirs(pause_img_folder, exist_ok=True)
|
||||
|
||||
img_list = [f for f in os.listdir(pause_img_folder) if os.path.isfile(os.path.join(pause_img_folder, f))]
|
||||
|
||||
if img_list:
|
||||
# Create vertical scrolling photo gallery
|
||||
photos_scroll = ScrollView(size_hint=(1, 1), do_scroll_y=True, do_scroll_x=False)
|
||||
photos_content = BoxLayout(orientation='vertical', spacing=2, size_hint_y=None, padding=[2, 2, 2, 2])
|
||||
photos_content.bind(minimum_height=photos_content.setter('height'))
|
||||
|
||||
for img_file in img_list:
|
||||
photo_item = self.create_vertical_photo_item(pause_idx, img_file, photos_content)
|
||||
photos_content.add_widget(photo_item)
|
||||
|
||||
photos_scroll.add_widget(photos_content)
|
||||
photos_layout.add_widget(photos_scroll)
|
||||
else:
|
||||
no_photos_label = Label(
|
||||
text="No photos added yet",
|
||||
font_size=12,
|
||||
color=(0.6, 0.6, 0.6, 1),
|
||||
size_hint_y=1,
|
||||
halign="center"
|
||||
)
|
||||
photos_layout.add_widget(no_photos_label)
|
||||
|
||||
return photos_layout
|
||||
|
||||
def create_vertical_photo_item(self, pause_idx, img_file, parent_layout):
|
||||
"""Create a photo item for vertical scrolling"""
|
||||
# Main container with border
|
||||
photo_item = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=60, padding=[2, 2, 2, 2])
|
||||
|
||||
# Add border and background to photo item
|
||||
with photo_item.canvas.before:
|
||||
Color(0.25, 0.25, 0.30, 1) # Background
|
||||
photo_item.bg_rect = Rectangle(pos=photo_item.pos, size=photo_item.size)
|
||||
Color(0.4, 0.4, 0.5, 1) # Border
|
||||
photo_item.border_line = Line(rectangle=(photo_item.x, photo_item.y, photo_item.width, photo_item.height), width=1)
|
||||
|
||||
def update_photo_item(instance, value, item=photo_item):
|
||||
item.bg_rect.pos = item.pos
|
||||
item.bg_rect.size = item.size
|
||||
item.border_line.rectangle = (item.x, item.y, item.width, item.height)
|
||||
photo_item.bind(pos=update_photo_item, size=update_photo_item)
|
||||
|
||||
# Get full path to the image
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||
img_path = os.path.join(pause_img_folder, img_file)
|
||||
|
||||
# Image thumbnail container
|
||||
image_container = BoxLayout(size_hint_x=None, width=55, padding=[2, 2, 2, 2])
|
||||
|
||||
try:
|
||||
photo_image = Image(
|
||||
source=img_path,
|
||||
size_hint=(1, 1),
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to a placeholder if image can't be loaded
|
||||
photo_image = Widget(size_hint=(1, 1))
|
||||
with photo_image.canvas:
|
||||
Color(0.3, 0.3, 0.3, 1)
|
||||
Rectangle(pos=photo_image.pos, size=photo_image.size)
|
||||
|
||||
image_container.add_widget(photo_image)
|
||||
|
||||
# Photo info layout (filename and details)
|
||||
info_layout = BoxLayout(orientation='vertical', spacing=2, size_hint_x=0.55, padding=[5, 2, 5, 2])
|
||||
|
||||
# Filename label (truncate if too long)
|
||||
display_name = img_file if len(img_file) <= 20 else f"{img_file[:17]}..."
|
||||
filename_label = Label(
|
||||
text=display_name,
|
||||
font_size=9,
|
||||
color=(1, 1, 1, 1),
|
||||
halign="left",
|
||||
valign="top",
|
||||
size_hint_y=0.6,
|
||||
bold=True
|
||||
)
|
||||
filename_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
|
||||
# File size and type info
|
||||
try:
|
||||
file_size = os.path.getsize(img_path)
|
||||
if file_size < 1024:
|
||||
size_text = f"{file_size} B"
|
||||
elif file_size < 1024*1024:
|
||||
size_text = f"{file_size/1024:.1f} KB"
|
||||
else:
|
||||
size_text = f"{file_size/(1024*1024):.1f} MB"
|
||||
|
||||
# Get file extension
|
||||
file_ext = os.path.splitext(img_file)[1].upper().replace('.', '')
|
||||
info_text = f"{file_ext} • {size_text}"
|
||||
except:
|
||||
info_text = "Unknown format"
|
||||
|
||||
size_label = Label(
|
||||
text=info_text,
|
||||
font_size=7,
|
||||
color=(0.8, 0.8, 0.8, 1),
|
||||
halign="left",
|
||||
valign="bottom",
|
||||
size_hint_y=0.4
|
||||
)
|
||||
size_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
|
||||
info_layout.add_widget(filename_label)
|
||||
info_layout.add_widget(size_label)
|
||||
|
||||
# Button layout for actions
|
||||
button_layout = BoxLayout(orientation='vertical', spacing=2, size_hint_x=0.28, padding=[2, 2, 2, 2])
|
||||
|
||||
# View button to show full image
|
||||
view_btn = Button(
|
||||
text="👁 View",
|
||||
font_size=8,
|
||||
background_color=(0.2, 0.6, 0.8, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
size_hint_y=0.5
|
||||
)
|
||||
view_btn.bind(on_press=lambda x: self.view_full_image(img_path, img_file))
|
||||
|
||||
# Delete button
|
||||
delete_btn = Button(
|
||||
text="🗑 Del",
|
||||
font_size=8,
|
||||
background_color=(0.8, 0.2, 0.2, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
size_hint_y=0.5
|
||||
)
|
||||
delete_btn.bind(on_press=lambda x: self.delete_single_photo(pause_idx, img_file, photo_item, parent_layout))
|
||||
|
||||
button_layout.add_widget(view_btn)
|
||||
button_layout.add_widget(delete_btn)
|
||||
|
||||
# Add all components to photo item
|
||||
photo_item.add_widget(image_container)
|
||||
photo_item.add_widget(info_layout)
|
||||
photo_item.add_widget(button_layout)
|
||||
|
||||
return photo_item
|
||||
|
||||
def delete_single_photo(self, pause_idx, img_file, photo_item, parent_layout):
|
||||
"""Delete a single photo with confirmation"""
|
||||
def confirm_delete():
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||
file_path = os.path.join(pause_img_folder, img_file)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
parent_layout.remove_widget(photo_item)
|
||||
parent_layout.height = max(20, len(parent_layout.children) * 62)
|
||||
|
||||
self.show_confirmation(
|
||||
f"Delete Photo",
|
||||
f"Are you sure you want to delete '{img_file}'?",
|
||||
confirm_delete
|
||||
)
|
||||
|
||||
def delete_pause(self, pause_idx):
|
||||
"""Delete an entire pause with confirmation"""
|
||||
def confirm_delete_pause():
|
||||
try:
|
||||
# Remove pause from the list
|
||||
if 0 <= pause_idx < len(self.pauses):
|
||||
self.pauses.pop(pause_idx)
|
||||
|
||||
# Save the updated pauses list
|
||||
self.save_pauses()
|
||||
|
||||
# Remove pause folder and its contents
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||
|
||||
if os.path.exists(pause_img_folder):
|
||||
shutil.rmtree(pause_img_folder)
|
||||
|
||||
# Reorganize remaining pause folders
|
||||
self.reorganize_pause_folders()
|
||||
|
||||
# Refresh the entire layout
|
||||
self.show_message("Pause Deleted", f"Pause {pause_idx + 1} has been deleted successfully!")
|
||||
Clock.schedule_once(lambda dt: self.build_pause_layout(), 0.5)
|
||||
|
||||
except Exception as e:
|
||||
self.show_message("Error", f"Failed to delete pause: {str(e)}")
|
||||
|
||||
self.show_confirmation(
|
||||
"Delete Pause",
|
||||
f"Are you sure you want to delete Pause {pause_idx + 1}?\nThis will remove the pause location and all its photos permanently.",
|
||||
confirm_delete_pause
|
||||
)
|
||||
|
||||
def reorganize_pause_folders(self):
|
||||
"""Reorganize pause folders after deletion to maintain sequential numbering"""
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
|
||||
# Get all existing pause folders
|
||||
existing_folders = []
|
||||
for item in os.listdir(project_folder):
|
||||
item_path = os.path.join(project_folder, item)
|
||||
if os.path.isdir(item_path) and item.startswith("pause_"):
|
||||
try:
|
||||
folder_num = int(item.split("_")[1])
|
||||
existing_folders.append((folder_num, item_path))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
|
||||
# Sort by folder number
|
||||
existing_folders.sort(key=lambda x: x[0])
|
||||
|
||||
# Rename folders to be sequential starting from 1
|
||||
temp_folders = []
|
||||
for i, (old_num, old_path) in enumerate(existing_folders):
|
||||
new_num = i + 1
|
||||
if old_num != new_num:
|
||||
# Create temporary name to avoid conflicts
|
||||
temp_path = os.path.join(project_folder, f"temp_pause_{new_num}")
|
||||
os.rename(old_path, temp_path)
|
||||
temp_folders.append((temp_path, new_num))
|
||||
else:
|
||||
temp_folders.append((old_path, new_num))
|
||||
|
||||
# Final rename to correct names
|
||||
for temp_path, new_num in temp_folders:
|
||||
final_path = os.path.join(project_folder, f"pause_{new_num}")
|
||||
if temp_path != final_path:
|
||||
if os.path.exists(final_path):
|
||||
shutil.rmtree(final_path)
|
||||
os.rename(temp_path, final_path)
|
||||
|
||||
def save_pause_name(self, pause, name_input):
|
||||
"""Save the custom name for a pause"""
|
||||
pause['name'] = name_input.text
|
||||
|
||||
def save_individual_pause(self, pause_idx):
|
||||
"""Save individual pause info"""
|
||||
self.save_pauses()
|
||||
# Show confirmation
|
||||
self.show_message("Pause Saved", f"Pause {pause_idx + 1} information saved successfully!")
|
||||
|
||||
def add_photos(self, pause_idx):
|
||||
"""Open file browser to add photos"""
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
with layout.canvas.before:
|
||||
Color(0.13, 0.13, 0.16, 1)
|
||||
layout.bg_rect = Rectangle(pos=layout.pos, size=layout.size)
|
||||
layout.bind(
|
||||
pos=lambda inst, val: setattr(layout.bg_rect, 'pos', inst.pos),
|
||||
size=lambda inst, val: setattr(layout.bg_rect, 'size', inst.size)
|
||||
)
|
||||
|
||||
title_label = Label(
|
||||
text=f"Select photos for Pause {pause_idx + 1}:",
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=14,
|
||||
size_hint_y=None,
|
||||
height=30
|
||||
)
|
||||
|
||||
filechooser = FileChooserIconView(
|
||||
filters=['*.png', '*.jpg', '*.jpeg', '*.gif', '*.bmp'],
|
||||
path=os.path.expanduser('~'),
|
||||
multiselect=True
|
||||
)
|
||||
|
||||
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=40)
|
||||
|
||||
add_btn = Button(
|
||||
text="Add Selected",
|
||||
background_color=(0.2, 0.7, 0.2, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=12
|
||||
)
|
||||
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
background_color=(0.6, 0.3, 0.3, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=12
|
||||
)
|
||||
|
||||
btn_layout.add_widget(cancel_btn)
|
||||
btn_layout.add_widget(add_btn)
|
||||
|
||||
layout.add_widget(title_label)
|
||||
layout.add_widget(filechooser)
|
||||
layout.add_widget(btn_layout)
|
||||
|
||||
popup = Popup(
|
||||
title="Add Photos",
|
||||
content=layout,
|
||||
size_hint=(0.95, 0.9),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
def add_selected_files(instance):
|
||||
if filechooser.selection:
|
||||
for file_path in filechooser.selection:
|
||||
if os.path.isfile(file_path):
|
||||
filename = os.path.basename(file_path)
|
||||
dest_path = os.path.join(pause_img_folder, filename)
|
||||
if not os.path.exists(dest_path):
|
||||
shutil.copy2(file_path, dest_path)
|
||||
Clock.schedule_once(lambda dt: self.refresh_photos_display(), 0.1)
|
||||
popup.dismiss()
|
||||
else:
|
||||
popup.dismiss()
|
||||
|
||||
add_btn.bind(on_press=add_selected_files)
|
||||
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def refresh_photos_display(self):
|
||||
"""Refresh the entire display to show updated photos"""
|
||||
self.build_pause_layout()
|
||||
|
||||
def show_message(self, title, message):
|
||||
"""Show a simple message popup"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
msg_label = Label(
|
||||
text=message,
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=12,
|
||||
halign="center"
|
||||
)
|
||||
msg_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
|
||||
ok_btn = Button(
|
||||
text="OK",
|
||||
size_hint_y=None,
|
||||
height=35,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
|
||||
layout.add_widget(msg_label)
|
||||
layout.add_widget(ok_btn)
|
||||
|
||||
popup = Popup(title=title, content=layout, size_hint=(0.8, 0.4))
|
||||
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
|
||||
def show_confirmation(self, title, message, confirm_callback):
|
||||
"""Show a confirmation dialog"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
msg_label = Label(
|
||||
text=message,
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=12,
|
||||
halign="center"
|
||||
)
|
||||
msg_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||
|
||||
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=35)
|
||||
|
||||
cancel_btn = Button(
|
||||
text="Cancel",
|
||||
background_color=(0.6, 0.3, 0.3, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
|
||||
confirm_btn = Button(
|
||||
text="Confirm",
|
||||
background_color=(0.8, 0.2, 0.2, 1),
|
||||
color=(1, 1, 1, 1)
|
||||
)
|
||||
|
||||
btn_layout.add_widget(cancel_btn)
|
||||
btn_layout.add_widget(confirm_btn)
|
||||
|
||||
layout.add_widget(msg_label)
|
||||
layout.add_widget(btn_layout)
|
||||
|
||||
popup = Popup(title=title, content=layout, size_hint=(0.8, 0.4), auto_dismiss=False)
|
||||
|
||||
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
confirm_btn.bind(on_press=lambda x: (confirm_callback(), popup.dismiss()))
|
||||
|
||||
popup.open()
|
||||
|
||||
def save_all_and_close(self, instance):
|
||||
"""Save all pauses and return to previous screen"""
|
||||
self.save_pauses()
|
||||
if self.on_save_callback:
|
||||
self.on_save_callback()
|
||||
self.go_back()
|
||||
|
||||
def go_back(self, instance=None):
|
||||
"""Return to the previous screen"""
|
||||
self.manager.current = "create_animation"
|
||||
|
||||
def set_project_and_callback(self, project_name, callback=None):
|
||||
"""Set the project name and callback for this screen"""
|
||||
self.project_name = project_name
|
||||
self.on_save_callback = callback
|
||||
|
||||
def view_full_image(self, img_path, img_file):
|
||||
"""Show full image in a popup"""
|
||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
with layout.canvas.before:
|
||||
Color(0.05, 0.05, 0.08, 1)
|
||||
layout.bg_rect = Rectangle(pos=layout.pos, size=layout.size)
|
||||
layout.bind(
|
||||
pos=lambda inst, val: setattr(layout.bg_rect, 'pos', inst.pos),
|
||||
size=lambda inst, val: setattr(layout.bg_rect, 'size', inst.size)
|
||||
)
|
||||
|
||||
# Image display
|
||||
try:
|
||||
full_image = Image(
|
||||
source=img_path,
|
||||
allow_stretch=True,
|
||||
keep_ratio=True
|
||||
)
|
||||
except Exception:
|
||||
full_image = Label(
|
||||
text="Unable to load image",
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=16
|
||||
)
|
||||
|
||||
# Close button
|
||||
close_btn = Button(
|
||||
text="Close",
|
||||
size_hint_y=None,
|
||||
height=40,
|
||||
background_color=(0.341, 0.235, 0.980, 1),
|
||||
color=(1, 1, 1, 1),
|
||||
font_size=14
|
||||
)
|
||||
|
||||
layout.add_widget(full_image)
|
||||
layout.add_widget(close_btn)
|
||||
|
||||
popup = Popup(
|
||||
title=f"Photo: {img_file}",
|
||||
content=layout,
|
||||
size_hint=(0.95, 0.95),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
close_btn.bind(on_press=lambda x: popup.dismiss())
|
||||
popup.open()
|
||||
134
screens/settings_screen.py
Normal file
134
screens/settings_screen.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import kivy
|
||||
from kivy.app import App
|
||||
from kivy.uix.screenmanager import ScreenManager, Screen
|
||||
import os
|
||||
import json
|
||||
from kivy.clock import Clock
|
||||
from kivy.properties import StringProperty, ListProperty
|
||||
from py_scripts.utils import (
|
||||
generate_key, load_key, encrypt_data, decrypt_data,
|
||||
check_server_settings, save_server_settings,
|
||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||
)
|
||||
from datetime import date
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from threading import Thread
|
||||
from kivy.clock import mainthread
|
||||
from kivy.uix.image import Image
|
||||
from kivy.uix.behaviors import ButtonBehavior
|
||||
from kivy.uix.progressbar import ProgressBar
|
||||
import webbrowser
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from PIL import Image
|
||||
import time
|
||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||
|
||||
class SettingsScreen(Screen):
|
||||
server_response = "Waiting to test connection..."
|
||||
|
||||
def on_pre_enter(self):
|
||||
settings = check_server_settings()
|
||||
if settings:
|
||||
self.ids.server_url_input.text = settings.get("server_url", "")
|
||||
self.ids.username_input.text = settings.get("username", "")
|
||||
self.ids.password_input.text = settings.get("password", "")
|
||||
self.ids.token_input.text = settings.get("token", "")
|
||||
else:
|
||||
self.ids.server_url_input.text = ""
|
||||
self.ids.username_input.text = ""
|
||||
self.ids.password_input.text = ""
|
||||
self.ids.token_input.text = ""
|
||||
|
||||
def test_connection(self):
|
||||
server_url = self.ids.server_url_input.text.strip()
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.password_input.text.strip()
|
||||
token = self.ids.token_input.text.strip()
|
||||
result = test_connection(server_url, username, password, token)
|
||||
self.server_response = result["message"]
|
||||
self.ids.result_label.text = self.server_response
|
||||
|
||||
def save_settings(self):
|
||||
server_url = self.ids.server_url_input.text.strip()
|
||||
username = self.ids.username_input.text.strip()
|
||||
password = self.ids.password_input.text.strip()
|
||||
token = self.ids.token_input.text.strip()
|
||||
if not server_url or not username or not password or not token:
|
||||
self.ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
settings_data = {
|
||||
"server_url": server_url,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"token": token,
|
||||
}
|
||||
try:
|
||||
save_server_settings(settings_data)
|
||||
self.ids.result_label.text = "Settings saved successfully!"
|
||||
self.manager.current = "home"
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Failed to save settings: {str(e)}"
|
||||
# get trip from server screen
|
||||
|
||||
|
||||
|
||||
# trhis screen is used to create a new user
|
||||
# register screen
|
||||
class RegisterScreen(Screen):
|
||||
def create_user(self):
|
||||
"""Handle user creation."""
|
||||
username = self.ids.set_username_input.text.strip()
|
||||
password = self.ids.set_password_input.text.strip()
|
||||
confirm_password = self.ids.confirm_password_input.text.strip()
|
||||
email = self.ids.set_email_input.text.strip()
|
||||
|
||||
if not username or not password or not confirm_password or not email:
|
||||
self.ids.result_label.text = "Please fill in all fields."
|
||||
return
|
||||
|
||||
if password != confirm_password:
|
||||
self.ids.result_label.text = "Passwords do not match."
|
||||
return
|
||||
|
||||
# Check if the username or email already exists
|
||||
if self.user_exists(username, email):
|
||||
self.ids.result_label.text = "User or email already exists."
|
||||
return
|
||||
|
||||
# Save user data (encrypted)
|
||||
user_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"email": email,
|
||||
}
|
||||
encrypted_data = encrypt_data(json.dumps(user_data))
|
||||
|
||||
try:
|
||||
with open(CREDENTIALS_FILE, "ab") as file: # Append encrypted data
|
||||
file.write(encrypted_data + b"\n") # Add a newline for separation
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Failed to save user: {str(e)}"
|
||||
return
|
||||
|
||||
self.ids.result_label.text = "User created successfully!"
|
||||
# Navigate back to the login screen
|
||||
self.manager.current = "login"
|
||||
def user_exists(self, username, email):
|
||||
"""Check if a username or email already exists in the credentials.enc file."""
|
||||
try:
|
||||
with open(CREDENTIALS_FILE, "rb") as file:
|
||||
for line in file:
|
||||
decrypted_data = decrypt_data(line.strip())
|
||||
user = json.loads(decrypted_data)
|
||||
if user["username"] == username or user["email"] == email:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.ids.result_label.text = f"Error checking user: {str(e)}"
|
||||
return False
|
||||
200
traccar.kv
200
traccar.kv
@@ -558,11 +558,10 @@
|
||||
on_press: app.root.current = "home"
|
||||
|
||||
<CreateAnimationScreen>:
|
||||
|
||||
BoxLayout:
|
||||
orientation: "vertical"
|
||||
padding: 20
|
||||
spacing: 20
|
||||
spacing: 18
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.11, 0.10, 0.15, 1
|
||||
@@ -570,14 +569,15 @@
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
|
||||
# Project title
|
||||
Label:
|
||||
text: root.project_name if root.project_name else "Create Animation Screen"
|
||||
text: root.project_name if root.project_name else "Create Animation"
|
||||
font_size: 22
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_y: None
|
||||
height: 20
|
||||
height: 36
|
||||
|
||||
# Rename frame (already present)
|
||||
# Rename frame
|
||||
BoxLayout:
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
@@ -608,7 +608,7 @@
|
||||
color: 1, 1, 1, 1
|
||||
on_press: root.open_rename_popup()
|
||||
|
||||
# New optimize route frame
|
||||
# Optimize route frame
|
||||
BoxLayout:
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
@@ -624,7 +624,7 @@
|
||||
|
||||
Label:
|
||||
id: route_entries_label
|
||||
text: "Your route has [number of entries]"
|
||||
text: "Your route has [number of entries],"
|
||||
font_size: 16
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
@@ -642,8 +642,87 @@
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
on_press: root.optimize_route_entries()
|
||||
|
||||
# Pauses frame
|
||||
BoxLayout:
|
||||
id: pauses_frame
|
||||
orientation: "horizontal"
|
||||
spacing: 10
|
||||
padding: 10
|
||||
size_hint_y: None
|
||||
height: 60
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.15, 0.15, 0.18, 1
|
||||
Rectangle:
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
Label:
|
||||
id: pauses_label
|
||||
text: "Pauses"
|
||||
font_size: 16
|
||||
halign: "left"
|
||||
valign: "middle"
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
text_size: self.width, None
|
||||
Button:
|
||||
id: pauses_edit_btn
|
||||
text: "Edit"
|
||||
size_hint_x: 0.3
|
||||
width: 120
|
||||
font_size: 16
|
||||
background_color: 0.341, 0.235, 0.980, 1
|
||||
color: 1, 1, 1, 1
|
||||
on_press: root.open_pauses_popup()
|
||||
|
||||
# Preview frame
|
||||
# Preview frame (label + button on first row, image on second row)
|
||||
BoxLayout:
|
||||
orientation: "vertical"
|
||||
size_hint_y: None
|
||||
height: 255 # Adjust as needed for your image size
|
||||
padding: [5, 5, 5, 5]
|
||||
spacing: 10
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.15, 0.15, 0.18, 1
|
||||
Rectangle:
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
|
||||
BoxLayout:
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
height: 30
|
||||
spacing: 5
|
||||
|
||||
Label:
|
||||
text: "Preview your route"
|
||||
font_size: 16
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
halign: "left"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
|
||||
Button:
|
||||
text: "Preview"
|
||||
size_hint_x: 0.3
|
||||
font_size: 16
|
||||
background_color: 0.341, 0.235, 0.980, 1
|
||||
color: 1, 1, 1, 1
|
||||
on_press: root.preview_route()
|
||||
|
||||
Image:
|
||||
id: preview_image
|
||||
source: root.preview_image_source
|
||||
allow_stretch: False
|
||||
keep_ratio: True
|
||||
size_hint_y: None
|
||||
height: 202
|
||||
size_hint_x: 1
|
||||
|
||||
# Progressive 3D Animation frame
|
||||
BoxLayout:
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
@@ -658,7 +737,7 @@
|
||||
size: self.size
|
||||
|
||||
Label:
|
||||
text: "Preview your route"
|
||||
text: "Generate progressive 3D animation\nBuilds trip point by point"
|
||||
font_size: 16
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
@@ -667,42 +746,86 @@
|
||||
text_size: self.size
|
||||
|
||||
Button:
|
||||
text: "Preview"
|
||||
text: "Generate\n3D Trip"
|
||||
size_hint_x: 0.3
|
||||
font_size: 16
|
||||
background_color: 0.341, 0.235, 0.980, 1
|
||||
font_size: 14
|
||||
background_color: 0.2, 0.8, 0.4, 1
|
||||
color: 1, 1, 1, 1
|
||||
on_press: root.preview_route()
|
||||
halign: "center"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
on_press: root.generate_progressive_3d_animation()
|
||||
|
||||
# HTML preview frame
|
||||
# Google Earth Animation frame
|
||||
BoxLayout:
|
||||
orientation: "vertical"
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
height: 220
|
||||
height: 60
|
||||
padding: [10, 10, 10, 10]
|
||||
spacing: 10
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.13, 0.13, 0.15, 1
|
||||
rgba: 0.15, 0.15, 0.18, 1
|
||||
Rectangle:
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
|
||||
# Use Kivy garden's WebView if available, else fallback to Image
|
||||
WebView:
|
||||
id: html_preview
|
||||
url: root.preview_html_path if root.preview_html_path else ""
|
||||
size_hint: 1, 1
|
||||
# If WebView is not available, comment this and use Image below
|
||||
Label:
|
||||
text: "Generate Google Earth flythrough\nCinematic aerial view"
|
||||
font_size: 16
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
halign: "left"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
|
||||
# If you don't have WebView, use this as a fallback:
|
||||
# Image:
|
||||
# id: html_preview_img
|
||||
# source: "resources/images/track.png"
|
||||
# allow_stretch: True
|
||||
# keep_ratio: True
|
||||
# size_hint: 1, 1
|
||||
Button:
|
||||
text: "Generate\nFlythrough"
|
||||
size_hint_x: 0.3
|
||||
font_size: 14
|
||||
background_color: 0.1, 0.8, 0.1, 1
|
||||
color: 1, 1, 1, 1
|
||||
halign: "center"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
on_press: root.generate_google_earth_animation()
|
||||
|
||||
# Blender Animation frame
|
||||
BoxLayout:
|
||||
orientation: "horizontal"
|
||||
size_hint_y: None
|
||||
height: 60
|
||||
padding: [10, 10, 10, 10]
|
||||
spacing: 10
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.15, 0.15, 0.18, 1
|
||||
Rectangle:
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
|
||||
Label:
|
||||
text: "Generate cinema-quality animation\nProfessional 3D rendering"
|
||||
font_size: 16
|
||||
color: 1, 1, 1, 1
|
||||
size_hint_x: 0.7
|
||||
halign: "left"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
|
||||
Button:
|
||||
text: "Generate\nCinema"
|
||||
size_hint_x: 0.3
|
||||
font_size: 14
|
||||
background_color: 0.9, 0.6, 0.2, 1
|
||||
color: 1, 1, 1, 1
|
||||
halign: "center"
|
||||
valign: "middle"
|
||||
text_size: self.size
|
||||
on_press: root.generate_blender_animation()
|
||||
|
||||
|
||||
|
||||
Widget:
|
||||
size_hint_y: 1
|
||||
|
||||
@@ -711,4 +834,19 @@
|
||||
size_hint_y: None
|
||||
height: 50
|
||||
background_color: 0.341, 0.235, 0.980, 1
|
||||
on_press: app.root.current = "home"
|
||||
color: 1, 1, 1, 1
|
||||
font_size: 16
|
||||
on_press: app.root.current = "home"
|
||||
|
||||
<PauseEditScreen>:
|
||||
BoxLayout:
|
||||
orientation: "vertical"
|
||||
spacing: 8
|
||||
padding: 8
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.11, 0.10, 0.15, 1
|
||||
Rectangle:
|
||||
pos: self.pos
|
||||
size: self.size
|
||||
|
||||
|
||||
168
utils.py
168
utils.py
@@ -1,168 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
RESOURCES_FOLDER = "resources"
|
||||
CREDENTIALS_FILE = os.path.join(RESOURCES_FOLDER, "credentials.enc")
|
||||
KEY_FILE = os.path.join(RESOURCES_FOLDER, "key.key")
|
||||
SERVER_SETTINGS_FILE = os.path.join(RESOURCES_FOLDER, "server_settings.enc")
|
||||
|
||||
# --- Encryption Utilities ---
|
||||
|
||||
def generate_key():
|
||||
"""Generate and save a key for encryption."""
|
||||
if not os.path.exists(KEY_FILE):
|
||||
key = Fernet.generate_key()
|
||||
with open(KEY_FILE, "wb") as key_file:
|
||||
key_file.write(key)
|
||||
|
||||
def load_key():
|
||||
"""Load the encryption key."""
|
||||
with open(KEY_FILE, "rb") as key_file:
|
||||
return key_file.read()
|
||||
|
||||
def encrypt_data(data):
|
||||
"""Encrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.encrypt(data.encode())
|
||||
|
||||
def decrypt_data(data):
|
||||
"""Decrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.decrypt(data).decode()
|
||||
|
||||
# --- Server Settings ---
|
||||
def check_server_settings():
|
||||
"""Load and decrypt server settings from file."""
|
||||
if not os.path.exists(SERVER_SETTINGS_FILE):
|
||||
return None
|
||||
try:
|
||||
with open(SERVER_SETTINGS_FILE, "rb") as file:
|
||||
encrypted_data = file.read()
|
||||
decrypted_data = decrypt_data(encrypted_data)
|
||||
settings = json.loads(decrypted_data)
|
||||
return settings
|
||||
except Exception as e:
|
||||
print(f"Failed to load server settings: {e}")
|
||||
return None
|
||||
|
||||
def save_server_settings(settings_data):
|
||||
"""Encrypt and save server settings."""
|
||||
encrypted_data = encrypt_data(json.dumps(settings_data))
|
||||
with open(SERVER_SETTINGS_FILE, "wb") as file:
|
||||
file.write(encrypted_data)
|
||||
|
||||
# --- Traccar Server Connection ---
|
||||
def test_connection(server_url, username=None, password=None, token=None):
|
||||
"""
|
||||
Test the connection with the Traccar server.
|
||||
Returns: dict with 'status' (bool) and 'message' (str)
|
||||
"""
|
||||
if not server_url:
|
||||
return {"status": False, "message": "Please provide the server URL."}
|
||||
if not token and (not username or not password):
|
||||
return {"status": False, "message": "Please provide either a token or username and password."}
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
auth = None if token else (username, password)
|
||||
response = requests.get(f"{server_url}/api/server", headers=headers, auth=auth, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return {"status": True, "message": "Connection successful! Server is reachable."}
|
||||
else:
|
||||
return {"status": False, "message": f"Error: {response.status_code} - {response.reason}"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"status": False, "message": "Connection timed out. Please try again."}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"status": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
# --- Device Fetching ---
|
||||
def get_devices_from_server():
|
||||
"""Retrieve a mapping of device names to IDs from the Traccar server."""
|
||||
settings = check_server_settings()
|
||||
if not settings:
|
||||
return None
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
if not server_url or not token:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
response = requests.get(f"{server_url}/api/devices", headers=headers)
|
||||
if response.status_code == 200:
|
||||
devices = response.json()
|
||||
return {device.get("name", "Unnamed Device"): device.get("id", "Unknown ID") for device in devices}
|
||||
else:
|
||||
print(f"Error: {response.status_code} - {response.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error retrieving devices: {str(e)}")
|
||||
return None
|
||||
|
||||
# --- Route Saving ---
|
||||
def save_route_to_file(route_name, positions, base_folder="resources/projects"):
|
||||
"""
|
||||
Save the given positions as a route in resources/projects/<route_name>/positions.json.
|
||||
Returns (success, message, file_path)
|
||||
"""
|
||||
if not route_name:
|
||||
return False, "Please enter a route name.", None
|
||||
if not positions:
|
||||
return False, "No positions to save.", None
|
||||
|
||||
folder_path = os.path.join(base_folder, route_name)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
file_path = os.path.join(folder_path, "positions.json")
|
||||
try:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(positions, f, indent=2)
|
||||
return True, f"Route '{route_name}' saved!", file_path
|
||||
except Exception as e:
|
||||
return False, f"Failed to save route: {str(e)}", None
|
||||
|
||||
def fetch_positions(server_url, token, device_id, from_time, to_time):
|
||||
"""
|
||||
Fetch positions from the Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
url = f"{server_url}/api/reports/route"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
params = {
|
||||
"deviceId": device_id,
|
||||
"from": from_time,
|
||||
"to": to_time
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json(), None
|
||||
elif response.status_code == 400:
|
||||
return None, "Bad Request: Please check the request payload and token."
|
||||
else:
|
||||
return None, f"Failed: {response.status_code} - {response.reason}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
return None, f"Error fetching positions: {str(e)}"
|
||||
|
||||
def fetch_positions_for_selected_day(settings, device_mapping, device_name, start_date, end_date, start_hour, end_hour):
|
||||
"""
|
||||
Fetch positions for the selected day/device using Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
if not settings:
|
||||
return [], "Server settings not found."
|
||||
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
device_id = device_mapping.get(device_name)
|
||||
if not device_id:
|
||||
return [], "Device ID not found."
|
||||
|
||||
from_time = f"{start_date}T{start_hour}:00:00Z"
|
||||
to_time = f"{end_date}T{end_hour}:59:59Z"
|
||||
|
||||
positions, error = fetch_positions(server_url, token, device_id, from_time, to_time)
|
||||
if error:
|
||||
return [], error
|
||||
return positions, None
|
||||
Reference in New Issue
Block a user