parent
a5044302a2
commit
942f4a3fcf
6 changed files with 1082 additions and 33 deletions
@ -0,0 +1,179 @@ |
||||
import threading |
||||
import numpy as np |
||||
from abc import ABC, abstractmethod |
||||
from typing import Any |
||||
from openpilot.tools.lib.logreader import LogReader |
||||
from openpilot.tools.lib.log_time_series import msgs_to_time_series |
||||
|
||||
|
||||
# TODO: support cereal/ZMQ streaming |
||||
class DataSource(ABC): |
||||
@abstractmethod |
||||
def load_data(self) -> dict[str, Any]: |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def get_duration(self) -> float: |
||||
pass |
||||
|
||||
|
||||
class LogReaderSource(DataSource): |
||||
def __init__(self, route_name: str): |
||||
self.route_name = route_name |
||||
self._duration = 0.0 |
||||
self._start_time_mono = 0.0 |
||||
|
||||
def load_data(self) -> dict[str, Any]: |
||||
lr = LogReader(self.route_name) |
||||
raw_time_series = msgs_to_time_series(lr) |
||||
processed_data = self._expand_list_fields(raw_time_series) |
||||
|
||||
# Calculate timing information |
||||
times = [data['t'] for data in processed_data.values() if 't' in data and len(data['t']) > 0] |
||||
if times: |
||||
all_times = np.concatenate(times) |
||||
self._start_time_mono = all_times.min() |
||||
self._duration = all_times.max() - self._start_time_mono |
||||
|
||||
return {'time_series_data': processed_data, 'route_start_time_mono': self._start_time_mono, 'duration': self._duration} |
||||
|
||||
def get_duration(self) -> float: |
||||
return self._duration |
||||
|
||||
# TODO: lists are expanded, but lists of structs are not |
||||
def _expand_list_fields(self, time_series_data): |
||||
expanded_data = {} |
||||
for msg_type, data in time_series_data.items(): |
||||
expanded_data[msg_type] = {} |
||||
for field, values in data.items(): |
||||
if field == 't': |
||||
expanded_data[msg_type]['t'] = values |
||||
continue |
||||
|
||||
if isinstance(values, np.ndarray) and values.dtype == object: # ragged array |
||||
lens = np.fromiter((len(v) for v in values), dtype=int, count=len(values)) |
||||
max_len = lens.max() if lens.size else 0 |
||||
if max_len > 0: |
||||
arr = np.full((len(values), max_len), None, dtype=object) |
||||
for i, v in enumerate(values): |
||||
arr[i, : lens[i]] = v |
||||
for i in range(max_len): |
||||
sub_arr = arr[:, i] |
||||
expanded_data[msg_type][f"{field}/{i}"] = sub_arr |
||||
elif isinstance(values, np.ndarray) and values.ndim > 1: # regular array |
||||
for i in range(values.shape[1]): |
||||
col_data = values[:, i] |
||||
expanded_data[msg_type][f"{field}/{i}"] = col_data |
||||
else: |
||||
expanded_data[msg_type][field] = values |
||||
return expanded_data |
||||
|
||||
|
||||
class DataLoadedEvent: |
||||
def __init__(self, data: dict[str, Any]): |
||||
self.data = data |
||||
|
||||
|
||||
class Observer(ABC): |
||||
@abstractmethod |
||||
def on_data_loaded(self, event: DataLoadedEvent): |
||||
pass |
||||
|
||||
|
||||
class DataManager: |
||||
def __init__(self): |
||||
self.time_series_data = {} |
||||
self.loading = False |
||||
self.route_start_time_mono = 0.0 |
||||
self.duration = 100.0 |
||||
self._observers: list[Observer] = [] |
||||
|
||||
def add_observer(self, observer: Observer): |
||||
self._observers.append(observer) |
||||
|
||||
def remove_observer(self, observer: Observer): |
||||
if observer in self._observers: |
||||
self._observers.remove(observer) |
||||
|
||||
def _notify_observers(self, event: DataLoadedEvent): |
||||
for observer in self._observers: |
||||
observer.on_data_loaded(event) |
||||
|
||||
def get_current_value_for_path(self, path: str, time_s: float, last_index: int | None = None): |
||||
try: |
||||
abs_time_s = self.route_start_time_mono + time_s |
||||
msg_type, field_path = path.split('/', 1) |
||||
ts_data = self.time_series_data[msg_type] |
||||
t, v = ts_data['t'], ts_data[field_path] |
||||
|
||||
if len(t) == 0: |
||||
return None, None |
||||
|
||||
if last_index is None: # jump |
||||
idx = np.searchsorted(t, abs_time_s, side='right') - 1 |
||||
else: # continuous playback |
||||
idx = last_index |
||||
while idx < len(t) - 1 and t[idx + 1] < abs_time_s: |
||||
idx += 1 |
||||
|
||||
idx = max(0, idx) |
||||
return v[idx], idx |
||||
|
||||
except (KeyError, IndexError): |
||||
return None, None |
||||
|
||||
def get_all_paths(self) -> list[str]: |
||||
all_paths = [] |
||||
for msg_type, data in self.time_series_data.items(): |
||||
for key in data.keys(): |
||||
if key != 't': |
||||
all_paths.append(f"{msg_type}/{key}") |
||||
return all_paths |
||||
|
||||
def is_path_plottable(self, path: str) -> bool: |
||||
try: |
||||
msg_type, field_path = path.split('/', 1) |
||||
value_array = self.time_series_data.get(msg_type, {}).get(field_path) |
||||
if value_array is not None: # only numbers and bools are plottable |
||||
return np.issubdtype(value_array.dtype, np.number) or np.issubdtype(value_array.dtype, np.bool_) |
||||
except (ValueError, KeyError): |
||||
pass |
||||
return False |
||||
|
||||
def get_time_series_data(self, path: str) -> tuple | None: |
||||
try: |
||||
msg_type, field_path = path.split('/', 1) |
||||
ts_data = self.time_series_data[msg_type] |
||||
time_array = ts_data['t'] |
||||
plot_values = ts_data[field_path] |
||||
|
||||
if len(time_array) == 0: |
||||
return None |
||||
|
||||
rel_time_array = time_array - self.route_start_time_mono |
||||
return rel_time_array, plot_values |
||||
|
||||
except (KeyError, ValueError): |
||||
return None |
||||
|
||||
def load_route(self, route_name: str): |
||||
if self.loading: |
||||
return |
||||
|
||||
self.loading = True |
||||
data_source = LogReaderSource(route_name) |
||||
threading.Thread(target=self._load_in_background, args=(data_source,), daemon=True).start() |
||||
|
||||
def _load_in_background(self, data_source: DataSource): |
||||
try: |
||||
data = data_source.load_data() |
||||
self.time_series_data = data['time_series_data'] |
||||
self.route_start_time_mono = data['route_start_time_mono'] |
||||
self.duration = data['duration'] |
||||
|
||||
self._notify_observers(DataLoadedEvent(data)) |
||||
|
||||
except Exception as e: |
||||
print(f"Error loading route: {e}") |
||||
finally: |
||||
self.loading = False |
@ -0,0 +1,304 @@ |
||||
import uuid |
||||
import dearpygui.dearpygui as dpg |
||||
from abc import ABC, abstractmethod |
||||
from openpilot.tools.jotpluggler.data import DataManager |
||||
from openpilot.tools.jotpluggler.views import ViewPanel, TimeSeriesPanel |
||||
|
||||
|
||||
class LayoutNode(ABC): |
||||
def __init__(self, node_id: str = None): |
||||
self.node_id = node_id or str(uuid.uuid4()) |
||||
self.tag = None |
||||
|
||||
@abstractmethod |
||||
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def destroy_ui(self): |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def preserve_data(self): |
||||
pass |
||||
|
||||
|
||||
class LeafNode(LayoutNode): |
||||
"""Leaf node that contains a single ViewPanel with controls""" |
||||
|
||||
def __init__(self, panel: ViewPanel, layout_manager=None, scale: float = 1.0, node_id: str = None): |
||||
super().__init__(node_id) |
||||
self.panel = panel |
||||
self.layout_manager = layout_manager |
||||
self.scale = scale |
||||
|
||||
def preserve_data(self): |
||||
self.panel.preserve_data() |
||||
|
||||
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): |
||||
"""Create UI container with controls and panel""" |
||||
self.tag = f"leaf_{self.node_id}" |
||||
|
||||
with dpg.child_window(tag=self.tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): |
||||
# Control bar |
||||
with dpg.group(horizontal=True): |
||||
dpg.add_input_text(tag=f"title_{self.node_id}", default_value=self.panel.title, width=int(100 * self.scale), callback=self._on_title_change) |
||||
dpg.add_combo( |
||||
items=["Time Series"], # "Camera", "Text Log", "Map View"], |
||||
tag=f"type_{self.node_id}", |
||||
default_value="Time Series", |
||||
width=int(100 * self.scale), |
||||
callback=self._on_type_change, |
||||
) |
||||
dpg.add_button(label="Clear", callback=self._clear, width=int(50 * self.scale)) |
||||
dpg.add_button(label="Delete", callback=self._delete, width=int(50 * self.scale)) |
||||
dpg.add_button(label="Split H", callback=lambda: self._split("horizontal"), width=int(50 * self.scale)) |
||||
dpg.add_button(label="Split V", callback=lambda: self._split("vertical"), width=int(50 * self.scale)) |
||||
|
||||
dpg.add_separator() |
||||
|
||||
# Panel content area |
||||
panel_area_tag = f"panel_area_{self.node_id}" |
||||
with dpg.child_window(tag=panel_area_tag, border=False, height=-1, width=-1, no_scrollbar=True): |
||||
self.panel.create_ui(panel_area_tag) |
||||
|
||||
def destroy_ui(self): |
||||
if self.panel: |
||||
self.panel.destroy_ui() |
||||
if self.tag and dpg.does_item_exist(self.tag): |
||||
dpg.delete_item(self.tag) |
||||
|
||||
def _on_title_change(self, sender, app_data): |
||||
self.panel.title = app_data |
||||
|
||||
def _on_type_change(self, sender, app_data): |
||||
print(f"Panel type change requested: {app_data}") |
||||
|
||||
def _split(self, orientation: str): |
||||
if self.layout_manager: |
||||
self.layout_manager.split_node(self, orientation) |
||||
|
||||
def _clear(self): |
||||
if hasattr(self.panel, 'clear_all_series'): |
||||
self.panel.clear_all_series() |
||||
|
||||
def _delete(self): |
||||
if self.layout_manager: |
||||
self.layout_manager.delete_node(self) |
||||
|
||||
|
||||
class SplitterNode(LayoutNode): |
||||
"""Splitter node that contains multiple child nodes""" |
||||
|
||||
def __init__(self, children: list[LayoutNode], orientation: str = "horizontal", node_id: str = None): |
||||
super().__init__(node_id) |
||||
self.children = children if children else [] |
||||
self.orientation = orientation |
||||
self.child_proportions = [1.0 / len(self.children) for _ in self.children] if self.children else [] |
||||
self.child_container_tags = [] # Track container tags for resizing (different from child tag) |
||||
|
||||
def preserve_data(self): |
||||
for child in self.children: |
||||
child.preserve_data() |
||||
|
||||
def add_child(self, child: LayoutNode, index: int = None): |
||||
if index is None: |
||||
self.children.append(child) |
||||
self.child_proportions.append(0.0) |
||||
else: |
||||
self.children.insert(index, child) |
||||
self.child_proportions.insert(index, 0.0) |
||||
self._redistribute_proportions() |
||||
|
||||
def remove_child(self, child: LayoutNode): |
||||
if child in self.children: |
||||
index = self.children.index(child) |
||||
self.children.remove(child) |
||||
self.child_proportions.pop(index) |
||||
child.destroy_ui() |
||||
if self.children: |
||||
self._redistribute_proportions() |
||||
|
||||
def replace_child(self, old_child: LayoutNode, new_child: LayoutNode): |
||||
try: |
||||
index = self.children.index(old_child) |
||||
self.children[index] = new_child |
||||
return index |
||||
except ValueError: |
||||
return None |
||||
|
||||
def _redistribute_proportions(self): |
||||
if self.children: |
||||
equal_proportion = 1.0 / len(self.children) |
||||
self.child_proportions = [equal_proportion for _ in self.children] |
||||
|
||||
def resize_children(self): |
||||
if not self.tag or not dpg.does_item_exist(self.tag): |
||||
return |
||||
|
||||
available_width, available_height = dpg.get_item_rect_size(dpg.get_item_parent(self.tag)) |
||||
|
||||
for i, container_tag in enumerate(self.child_container_tags): |
||||
if not dpg.does_item_exist(container_tag): |
||||
continue |
||||
|
||||
proportion = self.child_proportions[i] if i < len(self.child_proportions) else (1.0 / len(self.children)) |
||||
|
||||
if self.orientation == "horizontal": |
||||
new_width = max(100, int(available_width * proportion)) |
||||
dpg.configure_item(container_tag, width=new_width) |
||||
else: |
||||
new_height = max(100, int(available_height * proportion)) |
||||
dpg.configure_item(container_tag, height=new_height) |
||||
|
||||
child = self.children[i] if i < len(self.children) else None |
||||
if child and isinstance(child, SplitterNode): |
||||
child.resize_children() |
||||
|
||||
def create_ui(self, parent_tag: str, width: int = -1, height: int = -1): |
||||
self.tag = f"splitter_{self.node_id}" |
||||
self.child_container_tags = [] |
||||
|
||||
if self.orientation == "horizontal": |
||||
with dpg.group(tag=self.tag, parent=parent_tag, horizontal=True): |
||||
for i, child in enumerate(self.children): |
||||
proportion = self.child_proportions[i] |
||||
child_width = max(100, int(width * proportion)) |
||||
container_tag = f"child_container_{self.node_id}_{i}" |
||||
self.child_container_tags.append(container_tag) |
||||
|
||||
with dpg.child_window(tag=container_tag, width=child_width, height=-1, border=False, no_scrollbar=True, resizable_x=False): |
||||
child.create_ui(container_tag, child_width, height) |
||||
else: |
||||
with dpg.group(tag=self.tag, parent=parent_tag): |
||||
for i, child in enumerate(self.children): |
||||
proportion = self.child_proportions[i] |
||||
child_height = max(100, int(height * proportion)) |
||||
container_tag = f"child_container_{self.node_id}_{i}" |
||||
self.child_container_tags.append(container_tag) |
||||
|
||||
with dpg.child_window(tag=container_tag, width=-1, height=child_height, border=False, no_scrollbar=True, resizable_y=False): |
||||
child.create_ui(container_tag, width, child_height) |
||||
|
||||
def destroy_ui(self): |
||||
for child in self.children: |
||||
if child: |
||||
child.destroy_ui() |
||||
if self.tag and dpg.does_item_exist(self.tag): |
||||
dpg.delete_item(self.tag) |
||||
self.child_container_tags.clear() |
||||
|
||||
|
||||
class PlotLayoutManager: |
||||
def __init__(self, data_manager: DataManager, playback_manager, scale: float = 1.0): |
||||
self.data_manager = data_manager |
||||
self.playback_manager = playback_manager |
||||
self.scale = scale |
||||
self.root_node: LayoutNode = None |
||||
self.container_tag = "plot_layout_container" |
||||
self._initialize_default_layout() |
||||
|
||||
def _initialize_default_layout(self): |
||||
panel = TimeSeriesPanel(self.data_manager, self.playback_manager) |
||||
self.root_node = LeafNode(panel, layout_manager=self, scale=self.scale) |
||||
|
||||
def create_ui(self, parent_tag: str): |
||||
if dpg.does_item_exist(self.container_tag): |
||||
dpg.delete_item(self.container_tag) |
||||
|
||||
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): |
||||
if self.root_node: |
||||
container_width, container_height = dpg.get_item_rect_size(self.container_tag) |
||||
self.root_node.create_ui(self.container_tag, container_width, container_height) |
||||
|
||||
def on_viewport_resize(self): |
||||
if isinstance(self.root_node, SplitterNode): |
||||
self.root_node.resize_children() |
||||
|
||||
def split_node(self, node: LeafNode, orientation: str): |
||||
# create new panel for the split |
||||
new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager) # TODO: create same type of panel as the split |
||||
new_leaf = LeafNode(new_panel, layout_manager=self, scale=self.scale) |
||||
|
||||
parent_node, child_index = self._find_parent_and_index(node) |
||||
|
||||
if parent_node is None: # root node - create new splitter as root |
||||
node.preserve_data() |
||||
self.root_node = SplitterNode([node, new_leaf], orientation) |
||||
self._update_ui_for_node(self.root_node, self.container_tag) |
||||
elif isinstance(parent_node, SplitterNode) and parent_node.orientation == orientation: # same orientation - add to existing splitter |
||||
parent_node.add_child(new_leaf, child_index + 1) |
||||
self._update_ui_for_node(parent_node) |
||||
else: # different orientation - replace node with new splitter |
||||
node.preserve_data() |
||||
new_splitter = SplitterNode([node, new_leaf], orientation) |
||||
self._replace_child_in_parent(parent_node, node, new_splitter) |
||||
|
||||
def delete_node(self, node: LeafNode): # TODO: actually delete the node, not just the ui for the node |
||||
parent_node, child_index = self._find_parent_and_index(node) |
||||
|
||||
if parent_node is None: # root deletion - replace with new default |
||||
node.destroy_ui() |
||||
self._initialize_default_layout() |
||||
self._update_ui_for_node(self.root_node, self.container_tag) |
||||
elif isinstance(parent_node, SplitterNode): |
||||
parent_node.remove_child(node) |
||||
if len(parent_node.children) == 1: # collapse splitter --> leaf to just leaf |
||||
remaining_child = parent_node.children[0] |
||||
grandparent_node, parent_index = self._find_parent_and_index(parent_node) |
||||
|
||||
if grandparent_node is None: # promote remaining child to root |
||||
remaining_child.preserve_data() |
||||
parent_node.children.remove(remaining_child) |
||||
self.root_node = remaining_child |
||||
parent_node.destroy_ui() |
||||
self._update_ui_for_node(self.root_node, self.container_tag) |
||||
else: # replace splitter with remaining child in grandparent node |
||||
self._replace_child_in_parent(grandparent_node, parent_node, remaining_child) |
||||
else: # update splpitter contents |
||||
self._update_ui_for_node(parent_node) |
||||
|
||||
def _replace_child_in_parent(self, parent_node: SplitterNode, old_child: LayoutNode, new_child: LayoutNode): |
||||
old_child.preserve_data() # save data and for when recreating ui for the node |
||||
|
||||
child_index = parent_node.children.index(old_child) |
||||
child_container_tag = f"child_container_{parent_node.node_id}_{child_index}" |
||||
|
||||
parent_node.replace_child(old_child, new_child) |
||||
|
||||
# Clean up old child if it's being replaced (not just moved) |
||||
if old_child != new_child: |
||||
old_child.destroy_ui() |
||||
|
||||
if dpg.does_item_exist(child_container_tag): |
||||
dpg.delete_item(child_container_tag, children_only=True) |
||||
container_width, container_height = dpg.get_item_rect_size(child_container_tag) |
||||
new_child.create_ui(child_container_tag, container_width, container_height) |
||||
|
||||
def _update_ui_for_node(self, node: LayoutNode, container_tag: str = None): |
||||
node.preserve_data() |
||||
|
||||
if container_tag: # update node in a specific container (usually root) |
||||
dpg.delete_item(container_tag, children_only=True) |
||||
container_width, container_height = dpg.get_item_rect_size(container_tag) |
||||
node.create_ui(container_tag, container_width, container_height) |
||||
else: # update node in its current location (splitter updates) |
||||
if node.tag and dpg.does_item_exist(node.tag): |
||||
parent_container = dpg.get_item_parent(node.tag) |
||||
node.destroy_ui() |
||||
if parent_container and dpg.does_item_exist(parent_container): |
||||
parent_width, parent_height = dpg.get_item_rect_size(parent_container) |
||||
node.create_ui(parent_container, parent_width, parent_height) |
||||
|
||||
def _find_parent_and_index(self, target_node: LayoutNode) -> tuple: # TODO: probably can be stored in child |
||||
def search_recursive(node: LayoutNode, parent: LayoutNode = None, index: int = 0): |
||||
if node == target_node: |
||||
return parent, index |
||||
if isinstance(node, SplitterNode): |
||||
for i, child in enumerate(node.children): |
||||
result = search_recursive(child, node, i) |
||||
if result[0] is not None: |
||||
return result |
||||
return None, None |
||||
|
||||
return search_recursive(self.root_node) |
@ -0,0 +1,240 @@ |
||||
import argparse |
||||
import pyautogui |
||||
import subprocess |
||||
import dearpygui.dearpygui as dpg |
||||
import threading |
||||
from openpilot.tools.jotpluggler.data import DataManager, Observer, DataLoadedEvent |
||||
from openpilot.tools.jotpluggler.views import DataTreeView |
||||
from openpilot.tools.jotpluggler.layout import PlotLayoutManager, SplitterNode, LeafNode |
||||
|
||||
|
||||
class PlaybackManager: |
||||
def __init__(self): |
||||
self.is_playing = False |
||||
self.current_time_s = 0.0 |
||||
self.duration_s = 100.0 |
||||
self.last_indices = {} |
||||
|
||||
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 |
||||
|
||||
def seek(self, time_s: float): |
||||
self.is_playing = False |
||||
self.current_time_s = max(0.0, min(time_s, self.duration_s)) |
||||
self.last_indices.clear() |
||||
|
||||
def update_time(self, delta_t: float) -> 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 |
||||
return self.current_time_s |
||||
|
||||
def update_index(self, path: str, new_idx: int | None): |
||||
if new_idx is not None: |
||||
self.last_indices[path] = new_idx |
||||
|
||||
|
||||
def calculate_avg_char_width(font): |
||||
sample_text = "abcdefghijklmnopqrstuvwxyz0123456789" |
||||
if size := dpg.get_text_size(sample_text, font=font): |
||||
return size[0] / len(sample_text) |
||||
return None |
||||
|
||||
|
||||
def format_and_truncate(value, available_width: float, avg_char_width: float) -> str: |
||||
s = str(value) |
||||
max_chars = int(available_width / avg_char_width) - 3 |
||||
if len(s) > max_chars: |
||||
return s[: max(0, max_chars)] + "..." |
||||
return s |
||||
|
||||
|
||||
class MainController(Observer): |
||||
def __init__(self, scale: float = 1.0): |
||||
self.ui_lock = threading.Lock() |
||||
self.scale = scale |
||||
self.data_manager = DataManager() |
||||
self.playback_manager = PlaybackManager() |
||||
self._create_global_themes() |
||||
self.data_tree_view = DataTreeView(self.data_manager, self.ui_lock) |
||||
self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, scale=self.scale) |
||||
self.data_manager.add_observer(self) |
||||
self.avg_char_width = None |
||||
|
||||
def _create_global_themes(self): |
||||
with dpg.theme(tag="global_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="global_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) |
||||
|
||||
def on_data_loaded(self, event: DataLoadedEvent): |
||||
self.playback_manager.set_route_duration(event.data['duration']) |
||||
num_msg_types = len(event.data['time_series_data']) |
||||
dpg.set_value("load_status", f"Loaded {num_msg_types} message types") |
||||
dpg.configure_item("load_button", enabled=True) |
||||
dpg.configure_item("timeline_slider", max_value=event.data['duration']) |
||||
|
||||
def setup_ui(self): |
||||
with dpg.item_handler_registry(tag="tree_node_handler"): |
||||
dpg.add_item_toggled_open_handler(callback=self.data_tree_view.update_active_nodes_list) |
||||
|
||||
dpg.set_viewport_resize_callback(callback=self.on_viewport_resize) |
||||
|
||||
with dpg.window(tag="Primary Window"): |
||||
with dpg.group(horizontal=True): |
||||
# Left panel - Data tree |
||||
with dpg.child_window(label="Data Pool", width=300 * self.scale, tag="data_pool_window", border=True, resizable_x=True): |
||||
with dpg.group(horizontal=True): |
||||
dpg.add_input_text(tag="route_input", width=-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() |
||||
dpg.add_text("Available Data") |
||||
dpg.add_separator() |
||||
dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) |
||||
dpg.add_separator() |
||||
with dpg.group(tag="data_tree_container", track_offset=True): |
||||
pass |
||||
|
||||
# Right panel - Plots and timeline |
||||
with dpg.group(): |
||||
with dpg.child_window(label="Plot Window", border=True, height=-(30 + 13 * self.scale), tag="main_plot_area"): |
||||
self.plot_layout_manager.create_ui("main_plot_area") |
||||
|
||||
with dpg.child_window(label="Timeline", border=True): |
||||
with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False): |
||||
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # 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_button(label="Play", tag="play_pause_button", callback=self.toggle_play_pause, width=int(50 * self.scale)) |
||||
dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) |
||||
dpg.add_text("", tag="fps_counter") |
||||
|
||||
dpg.set_primary_window("Primary Window", True) |
||||
|
||||
def on_viewport_resize(self): |
||||
self.plot_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 search_data(self): |
||||
search_term = dpg.get_value("search_input") |
||||
self.data_tree_view.search_data(search_term) |
||||
|
||||
def toggle_play_pause(self, sender): |
||||
self.playback_manager.toggle_play_pause() |
||||
label = "Pause" if self.playback_manager.is_playing else "Play" |
||||
dpg.configure_item(sender, label=label) |
||||
|
||||
def timeline_drag(self, sender, app_data): |
||||
self.playback_manager.seek(app_data) |
||||
dpg.configure_item("play_pause_button", label="Play") |
||||
|
||||
def update_frame(self, font): |
||||
with self.ui_lock: |
||||
if self.avg_char_width is None: |
||||
self.avg_char_width = calculate_avg_char_width(font) # must be calculated after first frame |
||||
|
||||
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._update_timeline_indicators(new_time) |
||||
if not self.data_manager.loading and self.avg_char_width: |
||||
self._update_data_values() |
||||
|
||||
dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") |
||||
|
||||
def _update_data_values(self): |
||||
pool_width = dpg.get_item_rect_size("data_pool_window")[0] |
||||
value_column_width = pool_width * 0.5 |
||||
active_nodes = self.data_tree_view.active_leaf_nodes |
||||
|
||||
for node in active_nodes: |
||||
path = node.full_path |
||||
value_tag = f"value_{path}" |
||||
|
||||
if dpg.does_item_exist(value_tag) and dpg.is_item_visible(value_tag): |
||||
last_index = self.playback_manager.last_indices.get(path) |
||||
value, new_idx = self.data_manager.get_current_value_for_path(path, self.playback_manager.current_time_s, last_index) |
||||
|
||||
if value is not None: |
||||
self.playback_manager.update_index(path, new_idx) |
||||
formatted_value = format_and_truncate(value, value_column_width, self.avg_char_width) |
||||
dpg.set_value(value_tag, formatted_value) |
||||
|
||||
def _update_timeline_indicators(self, current_time_s: float): |
||||
def update_node_recursive(node): |
||||
if isinstance(node, LeafNode): |
||||
if hasattr(node.panel, 'update_timeline_indicator'): |
||||
node.panel.update_timeline_indicator(current_time_s) |
||||
elif isinstance(node, SplitterNode): |
||||
for child in node.children: |
||||
update_node_recursive(child) |
||||
|
||||
if self.plot_layout_manager.root_node: |
||||
update_node_recursive(self.plot_layout_manager.root_node) |
||||
|
||||
|
||||
def main(route_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("selfdrive/assets/fonts/Inter-Regular.ttf", int(13 * scale)) |
||||
dpg.bind_font(default_font) |
||||
|
||||
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 route_to_load: |
||||
dpg.set_value("route_input", route_to_load) |
||||
controller.load_route() |
||||
|
||||
dpg.show_viewport() |
||||
|
||||
# Main loop |
||||
while dpg.is_dearpygui_running(): |
||||
controller.update_frame(default_font) |
||||
dpg.render_dearpygui_frame() |
||||
|
||||
dpg.destroy_context() |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") |
||||
parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") |
||||
args = parser.parse_args() |
||||
main(route_to_load=args.route) |
@ -0,0 +1,308 @@ |
||||
import os |
||||
import re |
||||
import uuid |
||||
import threading |
||||
import dearpygui.dearpygui as dpg |
||||
from abc import ABC, abstractmethod |
||||
from openpilot.tools.jotpluggler.data import Observer, DataLoadedEvent, DataManager |
||||
|
||||
|
||||
class ViewPanel(ABC): |
||||
"""Abstract base class for all view panels that can be displayed in a plot container""" |
||||
|
||||
def __init__(self, panel_id: str = None): |
||||
self.panel_id = panel_id or str(uuid.uuid4()) |
||||
self.title = "Untitled Panel" |
||||
|
||||
@abstractmethod |
||||
def create_ui(self, parent_tag: str): |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def destroy_ui(self): |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def get_panel_type(self) -> str: |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def preserve_data(self): |
||||
pass |
||||
|
||||
|
||||
class TimeSeriesPanel(ViewPanel, Observer): |
||||
def __init__(self, data_manager: DataManager, playback_manager, panel_id: str = None): |
||||
super().__init__(panel_id) |
||||
self.data_manager = data_manager |
||||
self.playback_manager = playback_manager |
||||
self.title = "Time Series Plot" |
||||
self.plotted_series: set[str] = set() |
||||
self.plot_tag = None |
||||
self.x_axis_tag = None |
||||
self.y_axis_tag = None |
||||
self.timeline_indicator_tag = None |
||||
self._ui_created = False |
||||
|
||||
# Store series data for restoration and legend management |
||||
self._preserved_series_data = [] # TODO: the way we do this right now doesn't make much sense |
||||
self._series_legend_tags = {} # Maps series_path to legend tag |
||||
|
||||
self.data_manager.add_observer(self) |
||||
|
||||
def preserve_data(self): |
||||
self._preserved_series_data = [] |
||||
if self.plotted_series and self._ui_created: |
||||
for series_path in self.plotted_series: |
||||
time_value_data = self.data_manager.get_time_series_data(series_path) |
||||
if time_value_data: |
||||
self._preserved_series_data.append((series_path, time_value_data)) |
||||
|
||||
def create_ui(self, parent_tag: str): |
||||
self.plot_tag = f"plot_{self.panel_id}" |
||||
self.x_axis_tag = f"{self.plot_tag}_x_axis" |
||||
self.y_axis_tag = f"{self.plot_tag}_y_axis" |
||||
self.timeline_indicator_tag = f"{self.plot_tag}_timeline" |
||||
|
||||
with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): |
||||
dpg.add_plot_legend() |
||||
dpg.add_plot_axis(dpg.mvXAxis, label="", tag=self.x_axis_tag) |
||||
dpg.add_plot_axis(dpg.mvYAxis, label="", tag=self.y_axis_tag) |
||||
|
||||
timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag) |
||||
dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme") |
||||
|
||||
# Restore series from preserved data |
||||
if self._preserved_series_data: |
||||
self.plotted_series.clear() |
||||
for series_path, (rel_time_array, value_array) in self._preserved_series_data: |
||||
self._add_series_with_data(series_path, rel_time_array, value_array) |
||||
self._preserved_series_data = [] |
||||
|
||||
self._ui_created = True |
||||
|
||||
def update_timeline_indicator(self, current_time_s: float): |
||||
if not self._ui_created or not dpg.does_item_exist(self.timeline_indicator_tag): |
||||
return |
||||
|
||||
dpg.set_value(self.timeline_indicator_tag, [[current_time_s], [0]]) # vertical line position |
||||
|
||||
if self.plotted_series: # update legend labels with current values |
||||
for series_path in self.plotted_series: |
||||
last_index = self.playback_manager.last_indices.get(series_path) |
||||
value, new_idx = self.data_manager.get_current_value_for_path(series_path, current_time_s, last_index) |
||||
|
||||
if value is not None: |
||||
self.playback_manager.update_index(series_path, new_idx) |
||||
|
||||
if isinstance(value, (int, float)): |
||||
if isinstance(value, float): |
||||
formatted_value = f"{value:.4f}" if abs(value) < 1000 else f"{value:.3e}" |
||||
else: |
||||
formatted_value = str(value) |
||||
else: |
||||
formatted_value = str(value) |
||||
|
||||
# Update the series label to include current value |
||||
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" |
||||
legend_label = f"{series_path}: {formatted_value}" |
||||
|
||||
if dpg.does_item_exist(series_tag): |
||||
dpg.configure_item(series_tag, label=legend_label) |
||||
|
||||
def _add_series_with_data(self, series_path: str, rel_time_array, value_array): |
||||
if series_path in self.plotted_series: |
||||
return False |
||||
|
||||
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" |
||||
line_series_tag = dpg.add_line_series(x=rel_time_array.tolist(), y=value_array.tolist(), label=series_path, parent=self.y_axis_tag, tag=series_tag) |
||||
|
||||
dpg.bind_item_theme(line_series_tag, "global_line_theme") |
||||
|
||||
self.plotted_series.add(series_path) |
||||
dpg.fit_axis_data(self.x_axis_tag) |
||||
dpg.fit_axis_data(self.y_axis_tag) |
||||
return True |
||||
|
||||
def destroy_ui(self): |
||||
if self.plot_tag and dpg.does_item_exist(self.plot_tag): |
||||
dpg.delete_item(self.plot_tag) |
||||
|
||||
# self.data_manager.remove_observer(self) |
||||
self._series_legend_tags.clear() |
||||
self._ui_created = False |
||||
|
||||
def get_panel_type(self) -> str: |
||||
return "timeseries" |
||||
|
||||
def add_series(self, series_path: str) -> bool: |
||||
if series_path in self.plotted_series: |
||||
return False |
||||
|
||||
time_value_data = self.data_manager.get_time_series_data(series_path) |
||||
if time_value_data is None: |
||||
return False |
||||
|
||||
rel_time_array, value_array = time_value_data |
||||
return self._add_series_with_data(series_path, rel_time_array, value_array) |
||||
|
||||
def clear_all_series(self): |
||||
for series_path in self.plotted_series.copy(): |
||||
self.remove_series(series_path) |
||||
|
||||
def remove_series(self, series_path: str): |
||||
if series_path in self.plotted_series: |
||||
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" |
||||
if dpg.does_item_exist(series_tag): |
||||
dpg.delete_item(series_tag) |
||||
self.plotted_series.remove(series_path) |
||||
if series_path in self._series_legend_tags: # Clean up legend tag mapping |
||||
del self._series_legend_tags[series_path] |
||||
|
||||
def on_data_loaded(self, event: DataLoadedEvent): |
||||
for series_path in self.plotted_series.copy(): |
||||
self._update_series_data(series_path) |
||||
|
||||
def _update_series_data(self, series_path: str): |
||||
time_value_data = self.data_manager.get_time_series_data(series_path) |
||||
if time_value_data is None: |
||||
return False |
||||
|
||||
rel_time_array, value_array = time_value_data |
||||
series_tag = f"series_{self.panel_id}_{series_path.replace('/', '_')}" |
||||
|
||||
if dpg.does_item_exist(series_tag): |
||||
dpg.set_value(series_tag, [rel_time_array.tolist(), value_array.tolist()]) |
||||
dpg.fit_axis_data(self.x_axis_tag) |
||||
dpg.fit_axis_data(self.y_axis_tag) |
||||
return True |
||||
else: |
||||
self.plotted_series.discard(series_path) |
||||
return False |
||||
|
||||
def _on_series_drop(self, sender, app_data, user_data): |
||||
series_path = app_data |
||||
self.add_series(series_path) |
||||
|
||||
|
||||
class DataTreeNode: |
||||
def __init__(self, name: str, full_path: str = ""): |
||||
self.name = name |
||||
self.full_path = full_path |
||||
self.children = {} |
||||
self.is_leaf = False |
||||
|
||||
|
||||
class DataTreeView(Observer): |
||||
def __init__(self, data_manager: DataManager, ui_lock: threading.Lock): |
||||
self.data_manager = data_manager |
||||
self.ui_lock = ui_lock |
||||
self.current_search = "" |
||||
self.data_tree = DataTreeNode(name="root") |
||||
self.active_leaf_nodes = [] |
||||
self.data_manager.add_observer(self) |
||||
|
||||
def on_data_loaded(self, event: DataLoadedEvent): |
||||
with self.ui_lock: |
||||
self.populate_data_tree() |
||||
|
||||
def populate_data_tree(self): |
||||
if not dpg.does_item_exist("data_tree_container"): |
||||
return |
||||
|
||||
dpg.delete_item("data_tree_container", children_only=True) |
||||
search_term = self.current_search.strip().lower() |
||||
|
||||
self.data_tree = DataTreeNode(name="root") |
||||
all_paths = self.data_manager.get_all_paths() |
||||
|
||||
for path in sorted(all_paths): |
||||
if not self._should_display_path(path, search_term): |
||||
continue |
||||
|
||||
parts = path.split('/') |
||||
current_node = self.data_tree |
||||
current_path_prefix = "" |
||||
|
||||
for part in parts: |
||||
current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part |
||||
if part not in current_node.children: |
||||
current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix) |
||||
current_node = current_node.children[part] |
||||
|
||||
current_node.is_leaf = True |
||||
|
||||
self._create_ui_from_tree_recursive(self.data_tree, "data_tree_container", search_term) |
||||
self.update_active_nodes_list() |
||||
|
||||
def _should_display_path(self, path: str, search_term: str) -> bool: |
||||
if 'DEPRECATED' in path and not os.environ.get('SHOW_DEPRECATED'): |
||||
return False |
||||
return not search_term or search_term in path.lower() |
||||
|
||||
def _natural_sort_key(self, node: DataTreeNode): |
||||
node_type_key = node.is_leaf |
||||
parts = [int(p) if p.isdigit() else p.lower() for p in re.split(r'(\d+)', node.name) if p] |
||||
return (node_type_key, parts) |
||||
|
||||
def _create_ui_from_tree_recursive(self, node: DataTreeNode, parent_tag: str, search_term: str): |
||||
sorted_children = sorted(node.children.values(), key=self._natural_sort_key) |
||||
|
||||
for child in sorted_children: |
||||
if child.is_leaf: |
||||
is_plottable = self.data_manager.is_path_plottable(child.full_path) |
||||
|
||||
# Create draggable item |
||||
with dpg.group(parent=parent_tag) as draggable_group: |
||||
with dpg.table(header_row=False, borders_innerV=False, borders_outerH=False, borders_outerV=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_text(child.name) |
||||
dpg.add_text("N/A", tag=f"value_{child.full_path}") |
||||
|
||||
# Add drag payload if plottable |
||||
if is_plottable: |
||||
with dpg.drag_payload(parent=draggable_group, drag_data=child.full_path, payload_type="TIMESERIES_PAYLOAD"): |
||||
dpg.add_text(f"Plot: {child.full_path}") |
||||
|
||||
else: |
||||
node_tag = f"tree_{child.full_path}" |
||||
label = child.name |
||||
|
||||
if '/' not in child.full_path: |
||||
sample_count = len(self.data_manager.time_series_data.get(child.full_path, {}).get('t', [])) |
||||
label = f"{child.name} ({sample_count} samples)" |
||||
|
||||
should_open = bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_all_descendant_paths(child)) |
||||
|
||||
with dpg.tree_node(label=label, parent=parent_tag, tag=node_tag, default_open=should_open): |
||||
dpg.bind_item_handler_registry(node_tag, "tree_node_handler") |
||||
self._create_ui_from_tree_recursive(child, node_tag, search_term) |
||||
|
||||
def _get_all_descendant_paths(self, node: DataTreeNode): |
||||
for child_name, child_node in node.children.items(): |
||||
child_name_lower = child_name.lower() |
||||
if child_node.is_leaf: |
||||
yield child_name_lower |
||||
else: |
||||
for path in self._get_all_descendant_paths(child_node): |
||||
yield f"{child_name_lower}/{path}" |
||||
|
||||
def search_data(self, search_term: str): |
||||
self.current_search = search_term |
||||
self.populate_data_tree() |
||||
|
||||
def update_active_nodes_list(self, sender=None, app_data=None, user_data=None): |
||||
self.active_leaf_nodes = self.get_active_leaf_nodes(self.data_tree) |
||||
|
||||
def get_active_leaf_nodes(self, node: DataTreeNode): |
||||
active_leaves = [] |
||||
for child in node.children.values(): |
||||
if child.is_leaf: |
||||
active_leaves.append(child) |
||||
else: |
||||
node_tag = f"tree_{child.full_path}" |
||||
if dpg.does_item_exist(node_tag) and dpg.get_value(node_tag): |
||||
active_leaves.extend(self.get_active_leaf_nodes(child)) |
||||
return active_leaves |
Loading…
Reference in new issue