Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ build/

# Python files.
venv*/
*venv/
*.pyc

# IDE project settings.
Expand Down
90 changes: 60 additions & 30 deletions python/fusion_engine_client/analysis/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,37 @@ def _data_to_table(col_titles: List[str], values: List[List[Any]], row_major: bo
return table_html.replace('\n', '')


def _build_map_style(mapbox_token: Optional[str]):
"""!
@brief Build a `layout.map.style` value for a MapLibre-based Scattermap figure.

If a Mapbox access token is available, pull Mapbox satellite tiles via a custom raster style spec (the mechanism
MapLibre-based maps use in place of the old `layout.mapbox.accesstoken` field, which no longer exists). Otherwise,
fall back to Plotly's built-in token-free `satellite-streets` style, which serves ESRI World Imagery aerial tiles
(max zoom 16, lower resolution than Mapbox) with OpenMapTiles street labels drawn on top.
"""
if not mapbox_token:
return 'satellite-streets'

return {
'version': 8,
'sources': {
'mapbox-satellite': {
'type': 'raster',
'tiles': [
f'https://api.mapbox.com/v4/mapbox.satellite/{{z}}/{{x}}/{{y}}@2x.jpg90'
f'?access_token={mapbox_token}'
],
'tileSize': 256,
'attribution': '© Mapbox',
},
},
'layers': [
{'id': 'mapbox-satellite-layer', 'type': 'raster', 'source': 'mapbox-satellite'},
],
}


_page_template = '''\
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
Expand Down Expand Up @@ -1159,9 +1190,9 @@ def plot_map(self, mapbox_token, reference: Optional[ReferenceData] = None):
mapbox_token = self.get_mapbox_token(mapbox_token)
if mapbox_token is None or mapbox_token == "":
self.logger.info('*' * 80 + '\n\n' +
'Mapbox token not specified. Disabling satellite imagery. For satellite imagery,\n'
'please provide a Mapbox token using --mapbox-token or by setting the\n'
'MAPBOX_ACCESS_TOKEN environment variable.' +
'Mapbox token not specified. Falling back to lower-resolution free satellite\n'
'imagery. For high-resolution imagery, please provide a Mapbox token using\n'
'--mapbox-token or by setting the MAPBOX_ACCESS_TOKEN environment variable.' +
'\n\n' + '*' * 80)
self._mapbox_token_missing = True
mapbox_token = None
Expand Down Expand Up @@ -1244,28 +1275,28 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma

if np.any(is_nav_engine):
idx = is_nav_engine
map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx], name=name,
customdata=[customdata_all[i] for i in np.nonzero(idx)[0]],
hovertemplate=_with_bold_name(hovertemplate, name),
legendgroup=legendgroup, visible=visible, **style))
map_data.append(go.Scattermap(lat=lla_deg[0, idx], lon=lla_deg[1, idx], name=name,
customdata=[customdata_all[i] for i in np.nonzero(idx)[0]],
hovertemplate=_with_bold_name(hovertemplate, name),
legendgroup=legendgroup, visible=visible, **style))
indices_by_engine['Nav Engine'].append(len(map_data) - 1)

if np.any(is_gnss_rx):
idx = is_gnss_rx
style['marker']['opacity'] = 0.5
style['marker']['size'] = 5
gnss_name = name + ' (Receiver Solution)'
map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx],
name=gnss_name,
customdata=[customdata_all[i] for i in np.nonzero(idx)[0]],
hovertemplate=_with_bold_name(hovertemplate, gnss_name),
legendgroup=legendgroup, visible=visible, **style))
map_data.append(go.Scattermap(lat=lla_deg[0, idx], lon=lla_deg[1, idx],
name=gnss_name,
customdata=[customdata_all[i] for i in np.nonzero(idx)[0]],
hovertemplate=_with_bold_name(hovertemplate, gnss_name),
legendgroup=legendgroup, visible=visible, **style))
indices_by_engine['Receiver Solution'].append(len(map_data) - 1)

else:
# If there's no data, draw a dummy trace so it shows up in the legend anyway.
map_data.append(go.Scattermapbox(lat=[np.nan], lon=[np.nan], name=name, legendgroup=legendgroup,
visible='legendonly', **style))
map_data.append(go.Scattermap(lat=[np.nan], lon=[np.nan], name=name, legendgroup=legendgroup,
visible='legendonly', **style))
indices_by_engine['Nav Engine'].append(len(map_data) - 1)

# Read the pose data.
Expand Down Expand Up @@ -1340,7 +1371,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma

# Add reference/truth data to the map, if available, restricted to the time range covered by the pose data.
# Built as a separate list and prepended below (rather than appended to map_data directly) so the reference
# is drawn first -- Scattermapbox layers later traces on top, and we want the pose data on top of the
# is drawn first -- Scattermap layers later traces on top, and we want the pose data on top of the
# reference, not the other way around.
ref_traces = []
if reference is not None and (reference.is_stationary or overall_gps_t_min is not None):
Expand Down Expand Up @@ -1381,12 +1412,12 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma
trace_customdata = None
else:
trace_customdata = [ref_customdata[i] for i in np.nonzero(idx)[0]]
ref_traces.append(go.Scattermapbox(lat=ref_lla_deg[0, idx], lon=ref_lla_deg[1, idx],
name=name, mode='markers',
marker={'size': 8, 'color': color},
showlegend=True, legendgroup='ref',
customdata=trace_customdata,
hovertemplate=_with_bold_name(ref_hovertemplate, name)))
ref_traces.append(go.Scattermap(lat=ref_lla_deg[0, idx], lon=ref_lla_deg[1, idx],
name=name, mode='markers',
marker={'size': 8, 'color': color},
showlegend=True, legendgroup='ref',
customdata=trace_customdata,
hovertemplate=_with_bold_name(ref_hovertemplate, name)))

if ref_traces:
# Shift the pose traces' button indices to account for the reference traces now being inserted ahead of
Expand All @@ -1400,8 +1431,8 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma
# Create the map.
title = 'Vehicle Trajectory'
if mapbox_token is None:
title += '<br>For satellite imagery, please provide a Mapbox token using --mapbox-token or by setting ' \
'MAPBOX_ACCESS_TOKEN.'
title += '<br>For higher-resolution satellite imagery, please provide a Mapbox token using ' \
'--mapbox-token or by setting MAPBOX_ACCESS_TOKEN.'

layout = go.Layout(
autosize=True,
Expand All @@ -1412,16 +1443,15 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma
title=dict(text=title, x=0, xanchor='left', xref='paper'),
# Reduce padding around the map, leaving enough space for the title.
margin=dict(l=16, r=16, t=70, b=8),
mapbox=dict(
accesstoken=mapbox_token,
map=dict(
bearing=0,
center=dict(
lat=lla_deg[0, 0],
lon=lla_deg[1, 0],
),
pitch=0,
zoom=18,
style='open-street-map' if mapbox_token is None else 'satellite-streets',
style=_build_map_style(mapbox_token),
),
)

Expand Down Expand Up @@ -1464,7 +1494,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma
# shrink after the time scale renders.
#
# The map itself starts hidden (`visibility:hidden`, which still reserves its final layout space, unlike
# `display:none`) -- even with the container correctly sized up front, Plotly's own WebGL/mapbox-gl
# `display:none`) -- even with the container correctly sized up front, Plotly's own WebGL/MapLibre
# rendering doesn't necessarily catch up to a resize() call within the same paint, so revealing it right
# away can still show one frame at the wrong (window-sized) dimensions overlapping the slider. It's
# revealed by JS (plotly_map_time_slider.js) once Plotly itself reports the post-resize redraw is done.
Expand Down Expand Up @@ -3418,9 +3448,9 @@ def generate_index(self, reference: Optional[ReferenceData] = None, auto_open: b
if self._mapbox_token_missing:
self.summary += """\n
<p style="color: red">
Warning: Mapbox token not specified. Generated map using Open Street Maps
street data. For satellite imagery, please request a free access token from
https://account.mapbox.com/access-tokens, then provide the token by
Warning: Mapbox token not specified. Generated map using free, lower-resolution
satellite imagery. For high-resolution imagery, please request a free access
token from https://account.mapbox.com/access-tokens, then provide the token by
specifying --mapbox-token or setting the MAPBOX_ACCESS_TOKEN environment
variable.
</p>
Expand Down
51 changes: 28 additions & 23 deletions python/fusion_engine_client/parsers/file_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,35 +384,40 @@ def get_time_range(self, start: Union[Timestamp, float] = None, stop: Union[Time
raise IndexError(f'No P1 timestamps present in index. Cannot apply time bounds. '
f'[start={start}, stop={stop}]')
else:
# Note: The index stores only the integer part of the timestamp.

# If self._data['time'] ends _before_ `start``, use 0 as start_idx. If self._data['time'] ends _after_
# `end`, use len(self._data['time']) as end_idx.
with np.errstate(invalid='ignore'):
start_idx = find_first(self._data['time'] >= np.floor(start)) if start is not None else 0
end_idx = find_first(self._data['time'] >= stop) if stop is not None else len(self._data)

# Corner case: if all messages with timestamps are >= stop (i.e., the log starts after the stop time), if
# there are some messages at the start of the log that do not have timestamps, find_first() will include
# them but we don't want that. For example, if stop is 4 and we have:
# {nan, 6, 7, 8}
# we expect end_idx = -1 (i.e., nothing in range), not end_idx = 1 (i.e., include the nan message).
nan_idx = np.isnan(self._data['time'])
if end_idx >= 1 and np.all(nan_idx[:end_idx]):
end_idx = -1

# Note: start_idx or end_idx == -1 indicates there was no data in the time range.
# Both bounds are intentionally over-inclusive: the index stores only the integer part of each timestamp, so
# floor `start` to avoid dropping a message whose true time is in range. `stop` needs no adjustment.
#
# nan entries (no P1Time) never satisfy `>=`, so find_first() either lands on a timestamped entry or returns
# -1. Treat -1 as len(self._data): an empty range for `start`, or the end of the data for `stop`.
def _first_at_or_after(time_sec: float) -> int:
with np.errstate(invalid='ignore'):
idx = find_first(self._data['time'] >= time_sec)
return len(self._data) if idx < 0 else idx

# Messages without P1Time are in range while the surrounding P1 time is in range, so an omitted bound takes
# in the initial/final block of them at that end of the log. Keep this consistent with @ref
# TimeRange.is_in_range(), which is used to read a log with no index.
start_idx = _first_at_or_after(np.floor(start)) if start is not None else 0
end_idx = _first_at_or_after(stop) if stop is not None else len(self._data)

# With `start` omitted the range begins at index 0, which may cover an initial block of messages without
# P1Time. If it covers _nothing else_, no timestamped data is in range, so the range is empty: stop=4 over
# {nan, nan, 6, 7, 8} must yield nothing, not end_idx == 2. Note that is_in_range() would return the two
# leading messages; this is our one intentional deviation.
is_nan = np.isnan(self._data['time'])
if stop is not None and np.all(is_nan[start_idx:end_idx]):
end_idx = start_idx

idx = np.full_like(self._data['time'], False, dtype=bool)
if start_idx >= 0 and end_idx >= 0:
idx[start_idx:end_idx] = True
idx[start_idx:end_idx] = True

if hint in ('all_nans', 'remove_nans'):
if hint == 'all_nans':
idx[nan_idx] = True
idx[is_nan] = True
elif hint == 'remove_nans':
idx[nan_idx] = False
idx[is_nan] = False
elif hint != 'include_nans':
raise ValueError('Unrecognized control hint.')
raise ValueError(f'Unrecognized control hint: "{hint}".')

return FileIndex(data=self._data[idx], t0=self.t0)

Expand Down
Loading
Loading