fix(observatory): preserve camera across follow transitions

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 08:46:35 +03:00
parent cada687173
commit b2a1b23131
8 changed files with 268 additions and 76 deletions
+5 -2
View File
@@ -53,7 +53,7 @@ class _RecordedBlueprintStream:
Upstream 0.36.3 activates a clone, not this source store. Explicit refresh
makes layer changes visible. Ordinary layer updates deliberately omit eye
components so the active clone retains the operator's camera. Only an
explicit 3D/plan/follow transition may write a new spatial eye preset.
explicit 3D/plan/reset transition may write a new spatial eye preset.
"""
def __init__(
@@ -98,6 +98,9 @@ class _RecordedBlueprintStream:
raise RecordedBlueprintError("stable blueprint stream is closed")
eye_contract = (follow_trajectory, plan_view)
update_eye_controls = self._eye_contract != eye_contract
use_spatial_preset = (
self._eye_contract is not None and self._eye_contract[1] != plan_view
)
# Initial admission/reset uses native framing. Explicit presets
# apply to mode transitions, after the viewer has a source cursor.
# Keep one view identity for the lifetime of this browser owner.
@@ -108,7 +111,7 @@ class _RecordedBlueprintStream:
view_instance_token = self.blueprint_session_id
blueprint = blueprint_factory(
update_eye_controls,
self._eye_contract is not None,
use_spatial_preset,
view_instance_token,
)
# Appending rows alone does not refresh upstream's active clone.
+34 -3
View File
@@ -50,6 +50,7 @@ def _recorded_spatial_bounds_index(
rows: dict[str, list[tuple[int, np.ndarray, np.ndarray]]] = {
"/world/points": [],
"/world/trajectory": [],
"/world/sensor_pose": [],
}
for chunk in reader.stream(recording):
entity_path = str(chunk.entity_path)
@@ -59,9 +60,11 @@ def _recorded_spatial_bounds_index(
names = batch.column_names
if SESSION_TIMELINE not in names:
continue
component = (
"Points3D:positions" if entity_path == "/world/points" else "LineStrips3D:strips"
)
component = {
"/world/points": "Points3D:positions",
"/world/trajectory": "LineStrips3D:strips",
"/world/sensor_pose": "Transform3D:translation",
}[entity_path]
if component not in names:
continue
times = np.asarray(
@@ -100,6 +103,34 @@ def _recorded_spatial_bounds_index(
return result
def recorded_tracking_position(
recording_path: Path,
*,
current_time_ns: int,
) -> tuple[float, float, float] | None:
"""Return the latest scanner pivot at or before the playback cursor."""
if current_time_ns < 0:
raise ValueError("invalid recorded camera query")
path = recording_path.expanduser().absolute()
if path.is_symlink() or not path.is_file():
raise ValueError("recorded camera source is unavailable")
stat = path.stat()
series = _recorded_spatial_bounds_index(
str(path),
stat.st_size,
stat.st_mtime_ns,
).get("/world/sensor_pose")
if series is None:
return None
eligible_end = int(np.searchsorted(series.times_ns, current_time_ns, side="right"))
if eligible_end == 0:
return None
position = series.lower[eligible_end - 1]
return tuple(float(value) for value in position)
def recorded_orbital_radius_limit(
recording_path: Path,
*,
+70 -32
View File
@@ -52,7 +52,10 @@ from k1link.viewer.recorded import (
recorded_blueprint_sessions,
)
from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
from k1link.viewer.recorded_camera_bounds import recorded_orbital_radius_limit
from k1link.viewer.recorded_camera_bounds import (
recorded_orbital_radius_limit,
recorded_tracking_position,
)
from k1link.viewer.rerun_bridge import RerunSceneSettings
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
@@ -152,6 +155,7 @@ class RecordedBlueprintRequest(RecordedBlueprintIdentity):
eye_position: EyeVector | None = None
eye_look_target: EyeVector | None = None
eye_up: EyeVector | None = None
eye_relative_to_tracking: StrictBool = False
current_time_ns: int | None = Field(default=None, strict=True, ge=0, le=MAX_SAFE_INTEGER)
@model_validator(mode="after")
@@ -160,6 +164,8 @@ class RecordedBlueprintRequest(RecordedBlueprintIdentity):
if any(vector is None for vector in vectors):
if not all(vector is None for vector in vectors):
raise ValueError("all eye vectors must be supplied together")
if self.eye_relative_to_tracking:
raise ValueError("a tracking-relative eye requires all eye vectors")
return self
assert self.eye_position is not None
assert self.eye_look_target is not None
@@ -168,6 +174,8 @@ class RecordedBlueprintRequest(RecordedBlueprintIdentity):
raise ValueError("eye position and look target must differ")
if sum(value * value for value in self.eye_up) <= 1.0e-12:
raise ValueError("eye up vector must be non-zero")
if self.eye_relative_to_tracking and self.current_time_ns is None:
raise ValueError("a tracking-relative eye requires the playback cursor")
return self
@@ -1017,6 +1025,56 @@ def build_session_router(
False,
False,
)
camera_recording = None
if request.current_time_ns is not None:
if recording_preparation_manager is not None:
snapshot = recording_preparation_manager.status(session_id)
if (
snapshot is not None
and snapshot.state == "ready"
and snapshot.recording is not None
):
camera_recording = snapshot.recording
# A composition replay consumes the immutable base launch but
# does not GET its recording. Its short launch reservation can
# therefore expire while the combined RRD remains open. Restore
# the already-published base descriptor with bounded stat checks
# so later layer toggles still receive the native zoom limit.
if camera_recording is None and recording_materializer is not None:
camera_recording = await run_in_threadpool(
recording_materializer.restore_published,
command,
)
eye_position = request.eye_position
eye_look_target = request.eye_look_target
if request.eye_relative_to_tracking:
if camera_recording is None or request.current_time_ns is None:
raise HTTPException(
status_code=409,
detail="Позиция сканера для текущего кадра недоступна.",
)
tracking_position = await run_in_threadpool(
recorded_tracking_position,
camera_recording.path,
current_time_ns=request.current_time_ns,
)
if tracking_position is None:
raise HTTPException(
status_code=409,
detail="Позиция сканера для текущего кадра недоступна.",
)
assert eye_position is not None
assert eye_look_target is not None
eye_position = tuple(
tracking + position - target
for tracking, position, target in zip(
tracking_position,
eye_position,
eye_look_target,
strict=True,
)
)
eye_look_target = tracking_position
payload = await run_in_threadpool(
recorded_blueprint_rrd,
RerunSceneSettings(
@@ -1045,39 +1103,19 @@ def build_session_router(
show_costmap=request.show_costmap,
reactivate_updates=request.reactivate_updates,
follow_trajectory=request.follow_trajectory,
eye_position=request.eye_position,
eye_look_target=request.eye_look_target,
eye_position=eye_position,
eye_look_target=eye_look_target,
eye_up=request.eye_up,
)
if request.current_time_ns is not None:
camera_recording = None
if recording_preparation_manager is not None:
snapshot = recording_preparation_manager.status(session_id)
if (
snapshot is not None
and snapshot.state == "ready"
and snapshot.recording is not None
):
camera_recording = snapshot.recording
# A composition replay consumes the immutable base launch but
# does not GET its recording. Its short launch reservation can
# therefore expire while the combined RRD remains open. Restore
# the already-published base descriptor with bounded stat checks
# so later layer toggles still receive the native zoom limit.
if camera_recording is None and recording_materializer is not None:
camera_recording = await run_in_threadpool(
recording_materializer.restore_published,
command,
)
if camera_recording is not None:
camera_max_orbital_radius = await run_in_threadpool(
recorded_orbital_radius_limit,
camera_recording.path,
current_time_ns=request.current_time_ns,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
)
if request.current_time_ns is not None and camera_recording is not None:
camera_max_orbital_radius = await run_in_threadpool(
recorded_orbital_radius_limit,
camera_recording.path,
current_time_ns=request.current_time_ns,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
)
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (SessionNotReplayableError, SessionIntegrityError) as exc: