Skip to content

Add horizon leveling and harden re-orientation to the front - #643

Merged
millenbop merged 59 commits into
mapillary:mainfrom
borismasis:reorientation-fb15
Sep 25, 2026
Merged

millenbop merged 59 commits into
mapillary:mainfrom
borismasis:reorientation-fb15

Conversation

@borismasis

@borismasis borismasis commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Motivation

This follows up on #640 to make reorientation reliable across real-world navigation cases, including incomplete reconstruction data, unmerged panoramas, sequence changes, playback, and inconsistent capture poses.

The changes aim to keep navigation responsive while preserving the user’s viewing direction and avoiding sudden spins, horizon jumps, bad-pose flashes, and incompatible transitions.

Have you read the Contributing Guidelines?

Yes

Contribution

  • Preserve user intent and viewing direction across sequence navigation, direct jumps, playback, and spatial navigation.
  • Improve handling of missing, tilted, inconsistent, or unmerged reconstruction data.
  • Stabilize panorama orientation, horizon leveling, fallback transitions, and sequence-arrow placement.
  • Move reorientation preparation off the navigation-critical path and batch metadata lookahead within Graph API limits.
  • Prevent playback starvation on long sequences and prioritize the images needed for playback.
  • Add configuration and host integration APIs for predicting and adopting reoriented views.
  • Add sequence-control tooltips and remove the viewer canvas focus outline.
  • Expand unit coverage for reorientation, navigation, graph batching, camera behavior, and fallback rendering.

Test Plan

yarn lint

yarn compile-test

yarn jest --runInBand --silent

97 test suites passed

1,405 tests passed

Boris Masis added 30 commits September 25, 2026 13:31
Caching every image of a sequence is a batching optimization for the sequence
graph mode, but it gated image caching: it only completes once every batch has
been retrieved, which on long sequences takes long enough to starve playback.
Run it alongside the sequence request instead of in front of it.

Also resolve trajectory positions through a Map instead of Array.indexOf —
these are looked up on every animation frame, so the linear scans scaled with
sequence length — and return early when the trajectory reaches beyond the
sequence into another one, where there is nothing to request.
…jumps

Reorientation reset the view to the travel direction on every sequence change,
which threw away the view the user had navigated in with. Three changes:

Cross-sequence moves now follow the move's intent. Step* keeps the viewing
direction by definition, so a step into a new sequence carries the incoming view
unchanged and seeds the new sequence's look-around offset from it. Turn* is a
deliberate rotation, so it re-bases to the new sequence's travel direction while
preserving the look-around offset. Everything else — Next/Prev, map click,
shared link, fresh load — still resets to travel plus horizon.

The image stream is direction-blind, so Navigator records the direction of the
last move and the reorientation component consumes it synchronously with the
landing image. Spatial navigation does not all go through moveDir$: the keyboard
handler and the pano/step direction circles resolve the edge themselves and call
moveTo$, so moveTo$ takes the intended direction too and those call sites pass
it. Spherical counts as a step, since that is how panoramas navigate.

Within a sequence, a landing with no direction on an image that is not a
neighbour of the one we left is a jump (map click, pKey change), not a step. The
incoming view says nothing about the new position, so reorient even when the
landing image's GPS speed reads as stationary — previously such a jump kept the
old heading and left the user facing sideways.

Both neighbours are now pre-oriented rather than just the next one. Without a
hint, backward navigation only reorients on arrival past the 15 degree
threshold, so on gentle stretches it held the carried bearing. The engine
records prevId and warms the previous image to support this.

Adds getReorientedBearing(id), returning the heading an image will be shown at
from the precomputed cache, so a host application can point map UI at the same
direction the viewer will land on. The look-around offset applies only to
same-sequence ids, since a cross-sequence landing resets it.
…rners

Three changes to how the reorientation component decides what the user should
be looking at.

Arrows keep the view they carry. Crossing into a new sequence with a direction
arrow used to branch on Step* vs Turn*, rotating the base to the new sequence's
travel on a turn. The state layer has already matched the angle by then, so
rotating on top of it fought the transition the arrow just made. Both now keep
the carried view and adopt it as the new sequence's look-around offset. The same
applies to a sideways hop — an arrow landing somewhere other than the image next
to the one we left is a parallel pass or a doubling-back return leg, whose travel
can be the reverse of ours, and facing it would swing the user around.

Hosts can hand over a view. A shared link carries explicit basic coordinates the
user framed themselves, and the component never observed them, so the offset was
discarded and the next navigation snapped to travel. adoptView() takes those
coordinates as the look-around offset instead. Passing them in rather than
reading the viewer avoids racing the host applying them. reorientsOnSpatialNavTo()
exposes the arrow decision so a host can predict the landing heading, and
reorientOnSpatialNav opts out of in-sequence arrow reorientation to compare
against plain carried-view navigation.

Sharp corners are motion, not noise. The outlier test rejected a large bearing
change between two consecutive segments that were both at sustained speed. That
is a corner, not a bad fix — noise only reaches the test through the low-speed
branch — and rejecting it marked the sharpest corners, and via the history window
the whole stretch after them, as "not moving": exactly where reorientation is
wanted. The test now only applies when at least one segment is below the moving
speed. The low-speed distance floor drops from 2m to 0.5m for the same reason:
walking-pace capture puts frames about a metre apart, and the compass agreement
already separates that from a stationary camera's GPS drift.
When SfM compass data is unavailable, derive the panorama frame from the original compass angle and use the sequence GPS track for direction of movement.
Boris Masis added 25 commits September 25, 2026 13:32
Preserve the panorama view axis in engine results so public predictions and settled events return geographic bearings even when an unmerged panorama uses a fixed equirectangular axis.
@meta-cla meta-cla Bot added the cla signed label Sep 25, 2026
@borismasis borismasis changed the title Reorientation fb15 Add horizon leveling and harden re-orientation to the front Sep 25, 2026
@millenbop
millenbop merged commit 97ab9a6 into mapillary:main Sep 25, 2026
3 checks passed
@borismasis
borismasis deleted the reorientation-fb15 branch September 25, 2026 12:11
@borismasis borismasis mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants