openpilot/tools/jotpluggler/pluggle.py
Vehicle Researcher c5d5c5d1f3 openpilot v0.10.1 release
date: 2025-10-24T00:30:59
master commit: 405631baf9685e171a0dd19547cb763f1b163d18
2025-10-24 00:31:03 -07:00

371 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import os
import pyautogui
import subprocess
import dearpygui.dearpygui as dpg
import multiprocessing
import uuid
import signal
import yaml # type: ignore
from openpilot.common.swaglog import cloudlog
from openpilot.common.basedir import BASEDIR
from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.datatree import DataTree
from openpilot.tools.jotpluggler.layout import LayoutManager
DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"
class WorkerManager:
def __init__(self, max_workers=None):
self.pool = multiprocessing.Pool(max_workers or min(4, multiprocessing.cpu_count()), initializer=WorkerManager.worker_initializer)
self.active_tasks = {}
def submit_task(self, func, args_list, callback=None, task_id=None):
task_id = task_id or str(uuid.uuid4())
if task_id in self.active_tasks:
try:
self.active_tasks[task_id].terminate()
except Exception:
pass
def handle_success(result):
self.active_tasks.pop(task_id, None)
if callback:
try:
callback(result)
except Exception as e:
print(f"Callback for task {task_id} failed: {e}")
def handle_error(error):
self.active_tasks.pop(task_id, None)
print(f"Task {task_id} failed: {error}")
async_result = self.pool.starmap_async(func, args_list, callback=handle_success, error_callback=handle_error)
self.active_tasks[task_id] = async_result
return task_id
@staticmethod
def worker_initializer():
signal.signal(signal.SIGINT, signal.SIG_IGN)
def shutdown(self):
for task in self.active_tasks.values():
try:
task.terminate()
except Exception:
pass
self.pool.terminate()
self.pool.join()
class PlaybackManager:
def __init__(self):
self.is_playing = False
self.current_time_s = 0.0
self.duration_s = 0.0
self.num_segments = 0
self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time)
self.x_axis_observers = [] # callbacks for x-axis changes
self._updating_x_axis = False
def set_route_duration(self, duration: float):
self.duration_s = duration
self.seek(min(self.current_time_s, duration))
def toggle_play_pause(self):
if not self.is_playing and self.current_time_s >= self.duration_s:
self.seek(0.0)
self.is_playing = not self.is_playing
texture_tag = "pause_texture" if self.is_playing else "play_texture"
dpg.configure_item("play_pause_button", texture_tag=texture_tag)
def seek(self, time_s: float):
self.current_time_s = max(0.0, min(time_s, self.duration_s))
def update_time(self, delta_t: float):
if self.is_playing:
self.current_time_s = min(self.current_time_s + delta_t, self.duration_s)
if self.current_time_s >= self.duration_s:
self.is_playing = False
dpg.configure_item("play_pause_button", texture_tag="play_texture")
return self.current_time_s
def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None):
if self._updating_x_axis:
return
new_bounds = (min_time, max_time)
if new_bounds == self.x_axis_bounds:
return
self.x_axis_bounds = new_bounds
self._updating_x_axis = True # prevent recursive updates
try:
for callback in self.x_axis_observers:
try:
callback(min_time, max_time, source_panel)
except Exception as e:
print(f"Error in x-axis sync callback: {e}")
finally:
self._updating_x_axis = False
def add_x_axis_observer(self, callback):
if callback not in self.x_axis_observers:
self.x_axis_observers.append(callback)
def remove_x_axis_observer(self, callback):
if callback in self.x_axis_observers:
self.x_axis_observers.remove(callback)
class MainController:
def __init__(self, scale: float = 1.0):
self.scale = scale
self.data_manager = DataManager()
self.playback_manager = PlaybackManager()
self.worker_manager = WorkerManager()
self._create_global_themes()
self.data_tree = DataTree(self.data_manager, self.playback_manager)
self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale)
self.data_manager.add_observer(self.on_data_loaded)
self._total_segments = 0
def _create_global_themes(self):
with dpg.theme(tag="line_theme"):
with dpg.theme_component(dpg.mvLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
with dpg.theme(tag="timeline_theme"):
with dpg.theme_component(dpg.mvInfLineSeries):
scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)
for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
with dpg.theme(tag=tag):
for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
with dpg.theme_component(cmp):
dpg.add_theme_color(target, color)
with dpg.theme(tag="tab_bar_theme"):
with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))
def on_data_loaded(self, data: dict):
duration = data.get('duration', 0.0)
self.playback_manager.set_route_duration(duration)
if data.get('metadata_loaded'):
self.playback_manager.num_segments = data.get('total_segments', 0)
self._total_segments = data.get('total_segments', 0)
dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed")
elif data.get('reset'):
self.playback_manager.current_time_s = 0.0
self.playback_manager.duration_s = 0.0
self.playback_manager.is_playing = False
self._total_segments = 0
dpg.set_value("load_status", "Loading...")
dpg.set_value("timeline_slider", 0.0)
dpg.configure_item("timeline_slider", max_value=0.0)
dpg.configure_item("play_pause_button", texture_tag="play_texture")
dpg.configure_item("load_button", enabled=True)
elif data.get('loading_complete'):
num_paths = len(self.data_manager.get_all_paths())
dpg.set_value("load_status", f"Loaded {num_paths} data paths")
dpg.configure_item("load_button", enabled=True)
elif data.get('segment_added'):
segment_count = data.get('segment_count', 0)
dpg.set_value("load_status", f"Loading... {segment_count}/{self._total_segments} segments processed")
dpg.configure_item("timeline_slider", max_value=duration)
def save_layout_to_yaml(self, filepath: str):
layout_dict = self.layout_manager.to_dict()
with open(filepath, 'w') as f:
yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False)
def load_layout_from_yaml(self, filepath: str):
with open(filepath) as f:
layout_dict = yaml.safe_load(f)
self.layout_manager.clear_and_load_from_dict(layout_dict)
self.layout_manager.create_ui("main_plot_area")
def save_layout_dialog(self):
if dpg.does_item_exist("save_layout_dialog"):
dpg.delete_item("save_layout_dialog")
with dpg.file_dialog(
callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def load_layout_dialog(self):
if dpg.does_item_exist("load_layout_dialog"):
dpg.delete_item("load_layout_dialog")
with dpg.file_dialog(
callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def _save_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.save_layout_to_yaml(filepath)
dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error saving layout")
cloudlog.exception(f"Error saving layout to {filepath}")
dpg.delete_item("save_layout_dialog")
def _load_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.load_layout_from_yaml(filepath)
dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error loading layout")
cloudlog.exception(f"Error loading layout from {filepath}:")
dpg.delete_item("load_layout_dialog")
def setup_ui(self):
with dpg.texture_registry():
script_dir = os.path.dirname(os.path.realpath(__file__))
for image in ["play", "pause", "x", "split_h", "split_v", "plus"]:
texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png"))
dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture")
with dpg.window(tag="Primary Window"):
with dpg.group(horizontal=True):
# Left panel - Data tree
with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True):
with dpg.group(horizontal=True):
dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...")
dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
dpg.add_text("Ready to load route", tag="load_status")
dpg.add_separator()
with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp):
dpg.add_table_column(init_width_or_weight=0.5)
dpg.add_table_column(init_width_or_weight=0.5)
with dpg.table_row():
dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1)
dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1)
dpg.add_separator()
self.data_tree.create_ui("sidebar_window")
# Right panel - Plots and timeline
with dpg.group(tag="right_panel"):
with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"):
self.layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Timeline", border=True):
with dpg.table(header_row=False):
btn_size = int(13 * self.scale)
dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button
dpg.add_table_column(width_stretch=True) # Timeline slider
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter
with dpg.table_row():
dpg.add_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size)
dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag)
dpg.add_text("", tag="fps_counter")
with dpg.item_handler_registry(tag="plot_resize_handler"):
dpg.add_item_resize_handler(callback=self.on_plot_resize)
dpg.bind_item_handler_registry("right_panel", "plot_resize_handler")
dpg.set_primary_window("Primary Window", True)
def on_plot_resize(self, sender, app_data, user_data):
self.layout_manager.on_viewport_resize()
def load_route(self):
route_name = dpg.get_value("route_input").strip()
if route_name:
dpg.set_value("load_status", "Loading route...")
dpg.configure_item("load_button", enabled=False)
self.data_manager.load_route(route_name)
def toggle_play_pause(self, sender):
self.playback_manager.toggle_play_pause()
def timeline_drag(self, sender, app_data):
self.playback_manager.seek(app_data)
def update_frame(self, font):
self.data_tree.update_frame(font)
new_time = self.playback_manager.update_time(dpg.get_delta_time())
if not dpg.is_item_active("timeline_slider"):
dpg.set_value("timeline_slider", new_time)
self.layout_manager.update_all_panels()
dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS")
def shutdown(self):
self.worker_manager.shutdown()
def main(route_to_load=None, layout_to_load=None):
dpg.create_context()
# TODO: find better way of calculating display scaling
try:
w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution
scale = pyautogui.size()[0] / w # scaled resolution
except Exception:
scale = 1
with dpg.font_registry():
default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi
dpg.bind_font(default_font)
dpg.set_global_font_scale(0.5)
viewport_width, viewport_height = int(1200 * scale), int(800 * scale)
mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays)
dpg.create_viewport(
title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2
)
dpg.setup_dearpygui()
controller = MainController(scale=scale)
controller.setup_ui()
if layout_to_load:
try:
controller.load_layout_from_yaml(layout_to_load)
print(f"Loaded layout from {layout_to_load}")
except Exception as e:
print(f"Failed to load layout from {layout_to_load}: {e}")
cloudlog.exception(f"Error loading layout from {layout_to_load}")
if route_to_load:
dpg.set_value("route_input", route_to_load)
controller.load_route()
dpg.show_viewport()
# Main loop
try:
while dpg.is_dearpygui_running():
controller.update_frame(default_font)
dpg.render_dearpygui_frame()
finally:
controller.shutdown()
dpg.destroy_context()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.")
parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
parser.add_argument("--layout", type=str, help="Path to YAML layout file to load on startup")
parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.")
args = parser.parse_args()
route = DEMO_ROUTE if args.demo else args.route
main(route_to_load=route, layout_to_load=args.layout)