POI Types Editor tool has been added. Updates and fixes. Project structure has been changed. README updated.

This commit is contained in:
LudvvigB
2025-04-11 23:09:34 +03:00
parent b42df18f68
commit c23cb42a13
7 changed files with 532 additions and 204 deletions

0
src/__init__.py Normal file
View File

256
src/gpx2cnx.py Normal file
View File

@@ -0,0 +1,256 @@
# gpx2cnx.py
import os
import math
import codecs
import xml.etree.ElementTree as ET
from xml.dom import minidom
import tkinter as tk
from tkinter import filedialog, scrolledtext
import decimal
from decimal import Decimal, getcontext
import src.poi_types_editor as PE
getcontext().prec = 28
class GPX2CNX:
# Creating GUI and calling button functions
def __init__(self, root):
self.root = root
root.title("GPXtoCNX Converter")
self.status_label = tk.Label(root, text="", width=30)
self.status_label.grid(row=4, column=1, columnspan=3, sticky="ew", padx=25, pady=5)
self.button1 = tk.Button(root, text="GPX -> CNX", command=lambda:[self.status_label.config(text=""), self.select_files()])
self.button1.grid(row=1, column=1, columnspan=1, sticky="ew", padx=25, pady=5)
self.button2 = tk.Button(root, text="POI TYPES EDITOR", command=self.poi_editor)
self.button2.grid(row=2, column=1, columnspan=1, sticky="ew", padx=25, pady=5)
self.button3 = tk.Button(root, text="Log", command=self.log_window)
self.button3.grid(row=3, column=1, columnspan=1, sticky="ew", padx=25, pady=5)
root.grid_columnconfigure(1, weight=1)
def logging(self):
log = "gpx2cnx.py log"
for result in self.results:
log = log + "\n" + result
try:
with open("log.txt", "w", encoding="utf8") as logtxt:
logtxt.write(log + "\n")
except Exception as e:
self.status_label.config(text="* Error write log *")
if self.rstat == 1:
self.status_label.config(text="* DONE *")
else:
self.status_label.config(text="* ERROR *")
# Run POI Types Editor
def poi_editor(self):
window = tk.Toplevel()
PE.POIEditor(window)
window.grab_set()
# log_window
def log_window(self):
textinfo = ""
flogout = 0
try:
with open("log.txt", "r") as logtxt:
textinfo = logtxt.read()
flogout = 1
except FileNotFoundError:
self.status_label.config(text="* File not found *")
except Exception as e:
self.status_label.config(text="* Error read log *")
if flogout == 1:
loginfo = tk.Toplevel()
loginfo.title("Log")
info_area = scrolledtext.ScrolledText(loginfo, width=80, height=20)
info_area.pack(pady=10)
loginfo.grab_set()
info_area.insert(tk.END, textinfo)
# Select files and start conversion
def select_files(self):
file_paths = filedialog.askopenfilenames(filetypes=[("GPX files", "*.gpx")])
if file_paths:
self.convert_gpx2cnx(list(file_paths))
def prettify(self, elem):
rough_string = ET.tostring(elem, 'utf-8')
reparsed = minidom.parseString(rough_string)
return reparsed.toprettyxml(indent=" ")
# Calculate 3D distance between coordinates
def calc_distance(self):
R = 6371 * 1000 # Earth radius in meters
dlat = Decimal(str(math.radians(self.lat2 - self.lat1)))
dlon = Decimal(str(math.radians(self.lon2 - self.lon1)))
dele = Decimal(str(self.ele2 - self.ele1))
a = Decimal(str((math.sin(dlat/2) * math.sin(dlat/2)) +
(math.cos(math.radians(self.lat1)) * math.cos(math.radians(self.lat2)) *
math.sin(dlon/2) * math.sin(dlon/2))))
b = Decimal(str(2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))))
h_distance = R * b
distance = (h_distance**2 + dele**2).sqrt() # 3D distance
return distance
# Converts a list of GPX files to CNX xml format
def convert_gpx2cnx(self, gpx_files):
output_dir= os.path.join(os.path.normpath(os.path.split(gpx_files[0])[0]),'cnx_routes')
if not os.path.exists(output_dir):
os.makedirs(output_dir)
log = "gpx2cnx.py log"
self.results = []
for gpx_file in gpx_files:
try:
tree = ET.parse(gpx_file)
root = tree.getroot()
# Extracting waypoints
waypoints = []
for wpt in root.findall('{http://www.topografix.com/GPX/1/1}wpt'):
lat = Decimal(wpt.get('lat'))
lon = Decimal(wpt.get('lon'))
name = wpt.find('{http://www.topografix.com/GPX/1/1}name').text \
if wpt.find('{http://www.topografix.com/GPX/1/1}name') is not None else ""
waypoints.append({'lat': lat, 'lon': lon, 'name': name})
# Extracting track points
track_points = []
track = root.find('{http://www.topografix.com/GPX/1/1}trk')
if track is not None:
track_name = track.find('{http://www.topografix.com/GPX/1/1}name').text \
if track.find('{http://www.topografix.com/GPX/1/1}name') is not None else "Unknown"
trkseg = track.find('{http://www.topografix.com/GPX/1/1}trkseg')
if trkseg is not None:
for trkpt in trkseg.findall('{http://www.topografix.com/GPX/1/1}trkpt'):
lat = Decimal(trkpt.get('lat'))
lon = Decimal(trkpt.get('lon'))
ele = Decimal(trkpt.find('{http://www.topografix.com/GPX/1/1}ele').text) \
if trkpt.find('{http://www.topografix.com/GPX/1/1}ele') is not None else Decimal('0.0')
track_points.append({'lat': lat, 'lon': lon, 'ele': ele})
# Calc Distance, Ascent, Descent
distance = Decimal('0.0')
ascent = Decimal('0.0')
descent = Decimal('0.0')
if len(track_points) > 1:
for i in range(1, len(track_points)):
self.lat1 = track_points[i - 1]['lat']
self.lon1 = track_points[i - 1]['lon']
self.ele1 = track_points[i - 1]['ele']
self.lat2 = track_points[i]['lat']
self.lon2 = track_points[i]['lon']
self.ele2 = track_points[i]['ele']
distance += self.calc_distance()
ele_diff = self.ele2 - self.ele1
if ele_diff > 0:
ascent += ele_diff
else:
descent += ele_diff
distance = distance.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP)
ascent = ascent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP)
descent = descent.quantize(Decimal('1.00'), decimal.ROUND_HALF_UP)
# Calc Track - Relative Coordinates
if track_points:
first_lat = track_points[0]['lat']
first_lon = track_points[0]['lon']
first_ele = (track_points[0]['ele'] * 100).quantize(Decimal('1'), decimal.ROUND_HALF_UP)
relative_points = [f"{first_lat},{first_lon},{first_ele}"] # First Point - Absolute Coordinates
first_diffs = []
for i in range(1, len(track_points)): # Calc first diffs
lat1 = track_points[i-1]['lat']
lon1 = track_points[i-1]['lon']
lat2 = track_points[i]['lat']
lon2 = track_points[i]['lon']
lat_diff = (lat2 - lat1) * 10000000
lon_diff = (lon2 - lon1) * 10000000
ele_diff = track_points[i]['ele'] * 100 - track_points[i-1]['ele'] * 100
first_diffs.append((lat_diff, lon_diff, ele_diff))
if i == 1: # Second Point
lat_diff = lat_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP)
lon_diff = lon_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP)
ele_diff = ele_diff.quantize(Decimal('1'), decimal.ROUND_HALF_UP)
relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}")
for i in range(1, len(first_diffs)): # Calc second diffs
lat_diff = (first_diffs[i][0] - first_diffs[i-1][0]).quantize(Decimal('1'), decimal.ROUND_HALF_UP)
lon_diff = (first_diffs[i][1] - first_diffs[i-1][1]).quantize(Decimal('1'), decimal.ROUND_HALF_UP)
ele_diff = (first_diffs[i][2]).quantize(Decimal('1'), decimal.ROUND_HALF_UP)
relative_points.append(f"{lat_diff},{lon_diff},{ele_diff}")
# Create XML
route = ET.Element('Route')
ET.SubElement(route, 'Id').text = track_name # Use track name as Id
ET.SubElement(route, 'Distance').text = f'{distance}'
duration = ET.SubElement(route, 'Duration')
duration.text = '\n '
ET.SubElement(route, 'Ascent').text = f'{ascent}'
ET.SubElement(route, 'Descent').text = f'{descent}'
ET.SubElement(route, 'Encode').text = '2'
ET.SubElement(route, 'Lang').text = '0'
ET.SubElement(route, 'TracksCount').text = str(len(track_points))
if relative_points:
tracks_str = ';'.join(relative_points)
ET.SubElement(route, 'Tracks').text = tracks_str
else:
ET.SubElement(route, 'Tracks').text = ""
ET.SubElement(route, 'Navs')
ET.SubElement(route, 'PointsCount').text = str(len(waypoints))
points = ET.SubElement(route, 'Points')
for wpt in waypoints:
point = ET.SubElement(points, 'Point')
ET.SubElement(point, 'Lat').text = str(wpt['lat'])
ET.SubElement(point, 'Lng').text = str(wpt['lon'])
ET.SubElement(point, 'Type').text = '0' # Type 0 - point marker without differentiation by purpose
ET.SubElement(point, 'Descr').text = wpt['name']
filename = os.path.splitext(os.path.basename(gpx_file))[0]
trim_filename = filename[:18]
output_filename = os.path.join(output_dir, f"route_{trim_filename}.cnx")
xml_string = self.prettify(route)
with codecs.open(output_filename, "w", encoding='utf-8-sig') as f:
f.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
f.write(xml_string[xml_string.find('<Route'):])
self.results.append(f"-- {gpx_file} -> SUCCESS")
self.rstat = 1
except Exception as e:
self.results.append(f"ERROR - file {gpx_file}: {e}")
self.rstat = 0
self.logging()
self.logging()
return self.results
# Run app
if __name__ == "__main__":
root = tk.Tk()
app = GPX2CNX(root)
root.mainloop()

204
src/poi_types_editor.py Normal file
View File

@@ -0,0 +1,204 @@
# poi_types_editor.py
from pathlib import Path
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
from xml.etree import ElementTree as ET
import codecs
ROOT_DIR = Path(__file__).resolve().parents[1]
CONFIG_PATH = ROOT_DIR / 'config'
TYPES_LIST_FILE = 'poi_types_list.txt'
LIST_FILEPATH = CONFIG_PATH / TYPES_LIST_FILE
# Loads POI types from a file as a dictionary {code: name}
def load_poi_types(filepath=LIST_FILEPATH):
poi_types = {}
try:
with open(filepath, "r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if line:
parts = line.split(',')
if len(parts) == 2:
type_code = parts[0].strip()
type_name = parts[1].strip()
poi_types[type_code] = type_name
except FileNotFoundError:
poi_types = {"0": "Waypoint"} # default value
return poi_types
class POIEditor:
# Creating GUI and calling button functions
def __init__(self, root):
self.root = root
root.title("POI TYPES EDITOR")
self.poi_types = load_poi_types()
self.type_list_for_combo = list(self.poi_types.values())
self.points = []
self.current_index = 0
self.xml_filepath = None
self.xml_tree = None
self.xml_root = None
self.name_label = tk.Label(root, text="POI Name:")
self.name_label.grid(row=0, column=0, sticky="w", padx=5, pady=5)
self.name_text = tk.StringVar()
self.name_entry = ttk.Entry(root, textvariable=self.name_text, state="readonly", width=50)
self.name_entry.grid(row=0, column=1, columnspan=2, sticky="ew", padx=5, pady=5)
self.type_label = ttk.Label(root, text="POI Type:")
self.type_label.grid(row=1, column=0, sticky="w", padx=5, pady=5)
self.type_combo = ttk.Combobox(root, values=self.type_list_for_combo, state="readonly")
self.type_combo.grid(row=1, column=1,columnspan=3, sticky="ew", padx=5, pady=5)
self.type_combo.bind("<<ComboboxSelected>>", self.update_current_poi_type)
self.prev_button = ttk.Button(root, text="Prev", command=self.show_previous_poi, state="disabled")
self.prev_button.grid(row=2, column=0, sticky="ew", padx=5, pady=10)
self.next_button = ttk.Button(root, text="Next", command=self.show_next_poi, state="disabled")
self.next_button.grid(row=2, column=1, sticky="ew", padx=5, pady=10)
self.counter_label = ttk.Label(root, text="0/0")
self.counter_label.grid(row=2, column=2, sticky="ew", padx=5, pady=10)
self.save_button = ttk.Button(root, text="Save All", command=self.save_all_changes, state="disabled")
self.save_button.grid(row=3, column=0, columnspan=3, sticky="ew", padx=5, pady=10)
self.load_button = ttk.Button(root, text="Load CNX", command=self.load_cnx)
self.load_button.grid(row=4, column=0, columnspan=3, sticky="ew", padx=5, pady=10)
self.status_label = ttk.Label(root, text="")
self.status_label.grid(row=5, column=0, columnspan=3, sticky="e", padx=5, pady=5)
root.grid_columnconfigure(1, weight=1)
# Loads file and parses the data
def load_cnx(self):
filepath = filedialog.askopenfilename(
defaultextension=".cnx",
filetypes=[("CNX files", "*.cnx")],
title="Select CNX file"
)
if filepath:
try:
self.xml_tree = ET.parse(filepath)
self.xml_root = self.xml_tree.getroot()
points_element = self.xml_root.find("Points")
if points_element is not None:
self.points = []
for point_element in points_element.findall("Point"):
lat = point_element.findtext("Lat")
lng = point_element.findtext("Lng")
type_val = point_element.findtext("Type")
descr = point_element.findtext("Descr")
self.points.append({"lat": lat, "lng": lng, "type": type_val, "descr": descr, "element": point_element})
if self.points:
self.current_index = 0
self.show_current_poi()
self.prev_button["state"] = "disabled"
self.next_button["state"] = "normal" if len(self.points) > 1 else "disabled"
self.save_button["state"] = "normal"
self.status_label.config(text=f"* Loaded {len(self.points)} POI's *")
self.xml_filepath = filepath
else:
self.status_label.config(text="* No POI's found in the file *")
self.clear_ui()
self.xml_filepath = None
else:
self.status_label.config(text="* The <Points> element not found in the file *")
self.clear_ui()
xml_filepath = None
except ET.ParseError:
self.status_label.config(text="* CNX file parsing error *")
self.clear_ui()
self.xml_filepath = None
except FileNotFoundError:
self.status_label.config(text="* File not found *")
self.clear_ui()
self.xml_filepath = None
# Displays the current POI in the interface
def show_current_poi(self):
if self.points:
current_poi = self.points[self.current_index]
self.name_text.set(current_poi["descr"])
poi_type = self.poi_types.get(current_poi["type"], f"Unknown type ({current_poi['type']})")
self.type_combo.set(poi_type)
self.counter_label.config(text=f"{self.current_index + 1}/{len(self.points)}")
# Next POI
def show_next_poi(self):
if self.current_index < len(self.points) - 1:
self.current_index += 1
self.show_current_poi()
self.prev_button["state"] = "normal"
if self.current_index == len(self.points) - 1:
self.next_button["state"] = "disabled"
# Prev POI
def show_previous_poi(self):
if self.current_index > 0:
self.current_index -= 1
self.show_current_poi()
self.next_button["state"] = "normal"
if self.current_index == 0:
self.prev_button["state"] = "disabled"
# Returns the type code by type name
def get_type_code(self, type_name):
for code, name in self.poi_types.items():
if name == type_name:
return code
return "0" # default value
# Updates the type of the current POI in the self.points list when a value is selected in the Combobox
def update_current_poi_type(self, event):
if self.points and 0 <= self.current_index < len(self.points):
selected_type_name = self.type_combo.get()
selected_type_code = self.get_type_code(selected_type_name)
self.points[self.current_index]["type"] = selected_type_code
# Saves all changes to the original file
def save_all_changes(self):
if self.points and self.xml_filepath and self.xml_tree:
points_element = self.xml_root.find("Points")
if points_element is not None:
for i, poi in enumerate(self.points):
point_element = points_element[i]
point_element.find("Type").text = poi["type"]
try:
xml_string = ET.tostring(self.xml_root, encoding='unicode')
with codecs.open(self.xml_filepath, "w", encoding='utf-8-sig') as f:
f.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n')
f.write(xml_string[xml_string.find('<Route'):])
self.status_label.config(text="* SAVED *")
except Exception as e:
self.status_label.config(text=f"* File save error: {e} *")
else:
self.status_label.config(text="* Error: <Points> element not found for saving *")
elif not self.xml_filepath:
self.status_label.config(text="* CNX file not loaded *")
elif not self.xml_tree:
self.status_label.config(text="* Error working with the XML tree *")
# Clearing UI elements
def clear_ui(self):
self.name_text.set("")
self.type_combo.set("")
self.prev_button["state"] = "disabled"
self.next_button["state"] = "disabled"
self.save_button["state"] = "disabled"
# Run app
if __name__ == "__main__":
root = tk.Tk()
app = POIEditor(root)
root.mainloop()