jp: add thumbnail source (#38363)

This commit is contained in:
Adeeb Shihadeh
2026-07-18 09:02:08 -07:00
committed by GitHub
parent 3f93b00120
commit 3f49e2d33c
9 changed files with 347 additions and 4 deletions
+8 -1
View File
@@ -3,6 +3,7 @@
#include "tools/jotpluggler/common.h"
#include "tools/jotpluggler/internal.h"
#include "tools/jotpluggler/map.h"
#include "tools/jotpluggler/thumbnail.h"
#include "common/hardware/hw.h"
#include "imgui_impl_glfw.h"
@@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa
if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) {
pane.title = spec->label;
}
} else {
} else if (spec->kind == PaneKind::Camera) {
pane.title = spec->label;
resize_tab_pane_state(tab_state, tab->panes.size());
tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane = true;
} else {
pane.title = spec->label;
}
tab_state->active_pane_index = pane_index;
return true;
@@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) {
}
if (pane.kind == PaneKind::Map) {
draw_map_pane(session, state, &pane, static_cast<int>(i));
} else if (pane.kind == PaneKind::Thumbnail) {
draw_thumbnail_pane(session, state);
} else if (pane.kind == PaneKind::Camera) {
draw_camera_pane(session, state, tab_state, static_cast<int>(i), pane);
} else {
@@ -1847,6 +1852,7 @@ int run(const Options &options) {
for (std::unique_ptr<CameraFeedView> &feed : session.pane_camera_feeds) {
feed = std::make_unique<CameraFeedView>();
}
session.thumbnail_view = std::make_unique<ThumbnailView>();
sync_camera_feeds(&session);
if (session.async_route_loading) {
@@ -1892,6 +1898,7 @@ int run(const Options &options) {
for (std::unique_ptr<CameraFeedView> &feed : session.pane_camera_feeds) {
feed.reset();
}
session.thumbnail_view.reset();
return 0;
} catch (const std::exception &err) {
std::cerr << err.what() << "\n";
+27
View File
@@ -81,6 +81,7 @@ struct Curve {
enum class PaneKind : uint8_t {
Plot,
Map,
Thumbnail,
Camera,
};
@@ -141,6 +142,12 @@ struct CameraFeedIndex {
std::vector<CameraFrameIndexEntry> entries;
};
struct ThumbnailFrame {
double timestamp = 0.0;
int segment = -1;
std::vector<uint8_t> jpeg;
};
enum class LogOrigin : uint8_t {
Log,
OperatingSystem,
@@ -318,6 +325,7 @@ struct RouteData {
CameraFeedIndex driver_camera;
CameraFeedIndex wide_road_camera;
CameraFeedIndex qroad_camera;
std::vector<ThumbnailFrame> thumbnails;
GpsTrace gps_trace;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
@@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph,
class AsyncRouteLoader;
class CameraFeedView;
class ThumbnailView;
class StreamPoller;
class MapDataManager;
@@ -486,6 +495,7 @@ struct AppSession {
std::unique_ptr<AsyncRouteLoader> route_loader;
std::unique_ptr<StreamPoller> stream_poller;
std::array<std::unique_ptr<CameraFeedView>, 4> pane_camera_feeds;
std::unique_ptr<ThumbnailView> thumbnail_view;
std::unique_ptr<MapDataManager> map_data;
bool async_route_loading = false;
double next_stream_custom_refresh_time = 0.0;
@@ -885,3 +895,20 @@ private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class ThumbnailView {
public:
ThumbnailView();
~ThumbnailView();
ThumbnailView(const ThumbnailView &) = delete;
ThumbnailView &operator=(const ThumbnailView &) = delete;
void setThumbnails(const std::vector<ThumbnailFrame> &thumbnails);
void update(double tracker_time);
void drawSized(ImVec2 size, bool loading);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
+2 -2
View File
@@ -46,11 +46,11 @@ const char *special_item_label(std::string_view item_id) {
}
bool pane_kind_is_special(PaneKind kind) {
return kind == PaneKind::Map || kind == PaneKind::Camera;
return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera;
}
bool is_default_special_title(std::string_view title) {
if (title == "Map") return true;
if (title == "Map" || title == "Thumbnail") return true;
return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return title == spec.label;
});
+2 -1
View File
@@ -28,8 +28,9 @@ inline constexpr std::array<CameraViewSpec, 4> kCameraViewSpecs = {{
{CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera},
}};
inline constexpr std::array<SpecialItemSpec, 5> kSpecialItemSpecs = {{
inline constexpr std::array<SpecialItemSpec, 6> kSpecialItemSpecs = {{
{"map", "Map", PaneKind::Map, CameraViewKind::Road},
{"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road},
{kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view},
{kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view},
{kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view},
+2
View File
@@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa
};
if (pane.kind == PaneKind::Map) {
obj["kind"] = "map";
} else if (pane.kind == PaneKind::Thumbnail) {
obj["kind"] = "thumbnail";
} else if (pane.kind == PaneKind::Camera) {
obj["kind"] = "camera";
obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name;
+3
View File
@@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) {
session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view);
}
}
if (session->thumbnail_view) {
session->thumbnail_view->setThumbnails(session->route_data.thumbnails);
}
}
void apply_route_data(AppSession *session, UiState *state, RouteData route_data) {
@@ -98,6 +98,7 @@ struct LoadedRouteArtifacts {
std::vector<CanMessageData> can_messages;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
std::unordered_map<std::string, EnumInfo> enum_info;
};
@@ -684,6 +685,25 @@ std::vector<LogEntry> extract_segment_logs(const std::vector<Event> &events) {
return logs;
}
std::vector<ThumbnailFrame> extract_segment_thumbnails(const std::vector<Event> &events, int segment) {
std::vector<ThumbnailFrame> thumbnails;
for (const Event &event_record : events) {
if (event_record.which != cereal::Event::Which::THUMBNAIL) continue;
with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) {
const auto thumbnail = event.getThumbnail();
const auto jpeg = thumbnail.getThumbnail();
if (jpeg.size() == 0) return;
const uint64_t timestamp = thumbnail.getTimestampEof();
ThumbnailFrame frame;
frame.timestamp = static_cast<double>(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9;
frame.segment = segment;
frame.jpeg.assign(jpeg.begin(), jpeg.end());
thumbnails.push_back(std::move(frame));
});
}
return thumbnails;
}
RouteMetadata extract_segment_metadata(const std::vector<Event> &events) {
RouteMetadata metadata;
for (const Event &event_record : events) {
@@ -796,6 +816,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) {
const std::string kind = dock_area_node["kind"].string_value();
if (kind == "map") {
pane.kind = PaneKind::Map;
} else if (kind == "thumbnail") {
pane.kind = PaneKind::Thumbnail;
} else if (kind == "camera") {
pane.kind = PaneKind::Camera;
const std::string camera_view = dock_area_node["camera_view"].string_value();
@@ -1167,6 +1189,7 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
std::vector<CanMessageData> &&can_messages,
std::vector<LogEntry> &&logs,
std::vector<TimelineEntry> &&timeline,
std::vector<ThumbnailFrame> &&thumbnails,
std::unordered_map<std::string, EnumInfo> &&enum_info,
std::string car_fingerprint,
std::string dbc_name) {
@@ -1233,6 +1256,14 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
route_data.x_min = timeline.front().start_time;
route_data.x_max = timeline.back().end_time;
}
std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) {
return a.timestamp < b.timestamp;
});
if (!route_data.has_time_range && !thumbnails.empty()) {
route_data.has_time_range = true;
route_data.x_min = thumbnails.front().timestamp;
route_data.x_max = thumbnails.back().timestamp;
}
if (route_data.has_time_range) {
const double time_offset = route_data.x_min;
@@ -1254,6 +1285,9 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
entry.start_time -= time_offset;
entry.end_time -= time_offset;
}
for (ThumbnailFrame &thumbnail : thumbnails) {
thumbnail.timestamp -= time_offset;
}
route_data.x_max -= time_offset;
route_data.x_min = 0.0;
}
@@ -1271,6 +1305,7 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
merged_timeline.push_back(std::move(entry));
}
route_data.timeline = std::move(merged_timeline);
route_data.thumbnails = std::move(thumbnails);
std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) {
return std::make_tuple(a.id.service, a.id.bus, a.id.address)
< std::make_tuple(b.id.service, b.id.bus, b.id.address);
@@ -1524,6 +1559,7 @@ LoadedRouteArtifacts load_route_series_parallel(
SeriesAccumulator series;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
};
const std::vector<std::pair<int, SegmentLogs>> segment_list(segments.begin(), segments.end());
@@ -1586,6 +1622,7 @@ LoadedRouteArtifacts load_route_series_parallel(
results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers);
results[index].logs = extract_segment_logs(reader.events);
results[index].timeline = extract_segment_timeline(reader.events);
results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number);
segment_stats.extract_seconds = std::chrono::duration<double>(LoadStats::Clock::now() - extract_start).count();
segment_stats.event_count = reader.events.size();
segment_stats.series_count = populated_series_count(results[index].series);
@@ -1612,6 +1649,7 @@ LoadedRouteArtifacts load_route_series_parallel(
}
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
for (SegmentResult &result : results) {
if (!result.logs.empty()) {
logs.insert(logs.end(),
@@ -1623,12 +1661,18 @@ LoadedRouteArtifacts load_route_series_parallel(
std::make_move_iterator(result.timeline.begin()),
std::make_move_iterator(result.timeline.end()));
}
if (!result.thumbnails.empty()) {
thumbnails.insert(thumbnails.end(),
std::make_move_iterator(result.thumbnails.begin()),
std::make_move_iterator(result.thumbnails.end()));
}
}
LoadedRouteArtifacts artifacts;
artifacts.series = collect_series(std::move(merged));
artifacts.can_messages = std::move(merged.can_messages);
artifacts.logs = std::move(logs);
artifacts.timeline = std::move(timeline);
artifacts.thumbnails = std::move(thumbnails);
artifacts.enum_info = std::move(merged.enum_info);
stats->merge_end = LoadStats::Clock::now();
return artifacts;
@@ -1834,6 +1878,7 @@ RouteData load_route_data(const std::string &route_name,
std::move(artifacts.can_messages),
std::move(artifacts.logs),
std::move(artifacts.timeline),
std::move(artifacts.thumbnails),
std::move(artifacts.enum_info),
metadata.car_fingerprint,
resolved_dbc);
+253
View File
@@ -0,0 +1,253 @@
#include "tools/jotpluggler/thumbnail.h"
#include "imgui_impl_opengl3_loader.h"
#include <algorithm>
#include <cmath>
#include <limits>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/pixfmt.h>
}
namespace {
bool decode_jpeg(const std::vector<uint8_t> &jpeg, int *width, int *height, std::vector<uint8_t> *rgba) {
if (jpeg.empty()) return false;
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG);
AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr;
AVFrame *frame = av_frame_alloc();
AVPacket *packet = av_packet_alloc();
if (context == nullptr || frame == nullptr || packet == nullptr) {
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
const bool packet_ready = jpeg.size() <= static_cast<size_t>(std::numeric_limits<int>::max())
&& av_new_packet(packet, static_cast<int>(jpeg.size())) >= 0;
if (packet_ready) {
std::copy(jpeg.begin(), jpeg.end(), packet->data);
}
const bool decoded = packet_ready
&& avcodec_open2(context, codec, nullptr) >= 0
&& avcodec_send_packet(context, packet) >= 0
&& avcodec_receive_frame(context, frame) >= 0;
if (!decoded || frame->width <= 0 || frame->height <= 0) {
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
int chroma_x_shift = 0;
int chroma_y_shift = 0;
switch (static_cast<AVPixelFormat>(frame->format)) {
case AV_PIX_FMT_YUV420P:
case AV_PIX_FMT_YUVJ420P:
chroma_x_shift = 1;
chroma_y_shift = 1;
break;
case AV_PIX_FMT_YUV422P:
case AV_PIX_FMT_YUVJ422P:
chroma_x_shift = 1;
break;
case AV_PIX_FMT_YUV444P:
case AV_PIX_FMT_YUVJ444P:
break;
default:
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
*width = frame->width;
*height = frame->height;
rgba->resize(static_cast<size_t>(*width) * static_cast<size_t>(*height) * 4U);
const bool full_range = frame->color_range == AVCOL_RANGE_JPEG
|| frame->format == AV_PIX_FMT_YUVJ420P
|| frame->format == AV_PIX_FMT_YUVJ422P
|| frame->format == AV_PIX_FMT_YUVJ444P;
for (int y = 0; y < *height; ++y) {
const uint8_t *y_row = frame->data[0] + y * frame->linesize[0];
const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1];
const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2];
uint8_t *out = rgba->data() + static_cast<size_t>(y) * static_cast<size_t>(*width) * 4U;
for (int x = 0; x < *width; ++x) {
const double luma = full_range ? static_cast<double>(y_row[x])
: 1.164383 * (static_cast<double>(y_row[x]) - 16.0);
const double u = static_cast<double>(u_row[x >> chroma_x_shift]) - 128.0;
const double v = static_cast<double>(v_row[x >> chroma_x_shift]) - 128.0;
const double red = luma + (full_range ? 1.402 : 1.596027) * v;
const double green = luma - (full_range ? 0.344136 : 0.391762) * u
- (full_range ? 0.714136 : 0.812968) * v;
const double blue = luma + (full_range ? 1.772 : 2.017232) * u;
out[x * 4 + 0] = static_cast<uint8_t>(std::clamp(std::lround(red), 0L, 255L));
out[x * 4 + 1] = static_cast<uint8_t>(std::clamp(std::lround(green), 0L, 255L));
out[x * 4 + 2] = static_cast<uint8_t>(std::clamp(std::lround(blue), 0L, 255L));
out[x * 4 + 3] = 255;
}
}
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return true;
}
std::string format_thumbnail_time(double seconds) {
const int rounded = std::max(0, static_cast<int>(std::lround(seconds)));
const int hours = rounded / 3600;
const int minutes = (rounded % 3600) / 60;
const int secs = rounded % 60;
if (hours > 0) {
return util::string_format("%d:%02d:%02d", hours, minutes, secs);
}
return util::string_format("%02d:%02d", minutes, secs);
}
} // namespace
struct ThumbnailView::Impl {
~Impl() {
destroy_texture();
}
void setThumbnails(const std::vector<ThumbnailFrame> &next_thumbnails) {
destroy_texture();
thumbnails = &next_thumbnails;
displayed_index = -1;
failed_index = -1;
}
void update(double tracker_time) {
if (thumbnails == nullptr || thumbnails->empty()) return;
auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time,
[](const ThumbnailFrame &frame, double time) {
return frame.timestamp < time;
});
if (it == thumbnails->end()) {
it = std::prev(thumbnails->end());
} else if (it != thumbnails->begin()) {
const auto previous = std::prev(it);
if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) {
it = previous;
}
}
const int index = static_cast<int>(std::distance(thumbnails->begin(), it));
if (index == displayed_index || index == failed_index) return;
int width = 0;
int height = 0;
std::vector<uint8_t> rgba;
if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) {
failed_index = index;
return;
}
if (texture == 0) {
glGenTextures(1, &texture);
}
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data());
glBindTexture(GL_TEXTURE_2D, 0);
texture_width = width;
texture_height = height;
displayed_index = index;
failed_index = -1;
}
void drawSized(ImVec2 size, bool loading) const {
size.x = std::max(1.0f, size.x);
size.y = std::max(1.0f, size.y);
ImGui::InvisibleButton("##thumbnail_sized", size);
const ImVec2 pane_min = ImGui::GetItemRectMin();
const ImVec2 pane_max = ImGui::GetItemRectMax();
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255));
if (texture != 0 && texture_width > 0 && texture_height > 0) {
const float scale = std::min(size.x / static_cast<float>(texture_width),
size.y / static_cast<float>(texture_height));
const ImVec2 image_size(static_cast<float>(texture_width) * scale,
static_cast<float>(texture_height) * scale);
const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f,
pane_min.y + (size.y - image_size.y) * 0.5f);
const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y);
draw_list->AddImage(static_cast<ImTextureID>(texture), image_min, image_max);
if (thumbnails != nullptr && displayed_index >= 0
&& displayed_index < static_cast<int>(thumbnails->size())) {
const ThumbnailFrame &frame = (*thumbnails)[static_cast<size_t>(displayed_index)];
const std::string label = util::string_format("%s · segment %d · %d/%zu",
format_thumbnail_time(frame.timestamp).c_str(),
frame.segment,
displayed_index + 1,
thumbnails->size());
const ImVec2 text_size = ImGui::CalcTextSize(label.c_str());
const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f));
draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175));
draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str());
}
return;
}
const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty();
const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails");
const ImVec2 text_size = ImGui::CalcTextSize(label);
draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f,
pane_min.y + (size.y - text_size.y) * 0.5f),
IM_COL32(187, 187, 187, 255), label);
}
void destroy_texture() {
if (texture != 0) {
glDeleteTextures(1, &texture);
}
texture = 0;
texture_width = 0;
texture_height = 0;
}
const std::vector<ThumbnailFrame> *thumbnails = nullptr;
int displayed_index = -1;
int failed_index = -1;
GLuint texture = 0;
int texture_width = 0;
int texture_height = 0;
};
ThumbnailView::ThumbnailView() : impl_(std::make_unique<Impl>()) {}
ThumbnailView::~ThumbnailView() = default;
void ThumbnailView::setThumbnails(const std::vector<ThumbnailFrame> &thumbnails) {
impl_->setThumbnails(thumbnails);
}
void ThumbnailView::update(double tracker_time) {
impl_->update(tracker_time);
}
void ThumbnailView::drawSized(ImVec2 size, bool loading) {
impl_->drawSized(size, loading);
}
void draw_thumbnail_pane(AppSession *session, UiState *state) {
if (session->thumbnail_view == nullptr) {
ImGui::TextDisabled("Thumbnails unavailable");
return;
}
if (state->has_tracker_time) {
session->thumbnail_view->update(state->tracker_time);
}
session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading);
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "tools/jotpluggler/app.h"
void draw_thumbnail_pane(AppSession *session, UiState *state);