Repository navigation
egocap: align camera, glove and controller time; show latency and fps #1459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gareth-morgan-nv
wants to merge
54
commits into
main
Choose a base branch
from
gmorgan/EgocapTiming
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
54 commits
Select commit
Hold shift + click to select a range
ba73088
egocap: detect a step of this PC's clock during a LiveKit take
gareth-morgan-nv 8791f5f
egocap: tests for clock-step detection and the bracket uncertainty
gareth-morgan-nv b2b89b2
egocap: say the clock-step check is a heuristic with detection latency
gareth-morgan-nv 922488c
egocap: fix a line length in the clock-map docstring
gareth-morgan-nv af1c833
egocap: wrap the clock-map docstring to the line limit
gareth-morgan-nv cc295da
egocap: record the timestamp-alignment status and the source audit
gareth-morgan-nv bad4fc9
egocap emulator: model an Orin clock (offset, drift, capture-to-publi…
gareth-morgan-nv 29c7036
egocap: deterministic tests for the emulator's clock model and a synt…
gareth-morgan-nv 2c13777
egocap: show each LiveKit camera tile's displayed frame rate
gareth-morgan-nv ad55e75
egocap: tests for the displayed frame rate label
gareth-morgan-nv b545c7a
egocap: make the timeline test's drift grow with run time
gareth-morgan-nv c3837d7
egocap: show capture-to-display latency on each LiveKit camera tile
gareth-morgan-nv 6a1876c
egocap: tests for the capture-to-display latency label
gareth-morgan-nv 111521d
egocap: show the publisher-stamp-to-PC-receipt delay on each LiveKit …
gareth-morgan-nv ee21c6e
egocap: tests for the backend latency probe, its route, the polling h…
gareth-morgan-nv 55395b4
egocap: reset the FPS window on tab visibility changes
gareth-morgan-nv c56fcb9
egocap: tests for the tab visibility reset of the FPS window
gareth-morgan-nv 1da52df
egocap: remove the latency figures until a qualified path exists
gareth-morgan-nv dd38a9f
egocap: four-timestamp clock exchange estimator (pure, no transport)
gareth-morgan-nv 1e00040
egocap emulator: answer clock-sync requests with its own monotonic clock
gareth-morgan-nv 9bc9205
egocap: estimator tests; keep a tied mapping fresh and see a step at …
gareth-morgan-nv c66ef03
egocap: PC-side clock-sync client over LiveKit data messages; tests f…
gareth-morgan-nv 2ca41ad
egocap: tests for the emulator's monotonic clock and clock-sync replies
gareth-morgan-nv e6560bb
egocap: let the LiveKit receiver run a clock-sync client when asked
gareth-morgan-nv fec56b0
egocap: record LiveKit video through the four-timestamp clock mapping…
gareth-morgan-nv 021d4fc
egocap: document the four-timestamp clock mapping and the recordable …
gareth-morgan-nv ddc7f87
egocap: list the frame-callback fields the browser reports in the lat…
gareth-morgan-nv 68ca823
egocap: test that the latency tooltip names the browser's frame-callb…
gareth-morgan-nv f200542
egocap frontend: livekit-client 2.19.2 for per-frame packet-trailer m…
gareth-morgan-nv 57317e3
egocap: capture-to-display latency from the publisher's per-frame sta…
gareth-morgan-nv 4b4cf73
egocap: judge clock-mapping staleness by the newest exchange; port th…
gareth-morgan-nv 2b16b32
egocap: show just FPS and latency ms per tile, both windowed averages
gareth-morgan-nv 84a6f48
egocap: tile text reads 'XX fps' and 'YY ms latency'; tests for the b…
gareth-morgan-nv 18e2094
egocap: remove a scratch probe test left in the frontend
gareth-morgan-nv b3e0a38
egocap: bound the timer-resolution probe by iterations as well as time
gareth-morgan-nv d601071
egocap: FPS from frame endpoints, no UI-thread timing loop, age-aware…
gareth-morgan-nv 74bbb2f
egocap: tests for the age-aware clock mapping, FPS frame endpoints, a…
gareth-morgan-nv 963be9d
egocap: record the clock mappings a take was made under in its attrib…
gareth-morgan-nv 186b4c1
egocap: save each clock exchange once in a take's timing evidence, wi…
gareth-morgan-nv bb4680f
egocap: end a take when the clock mapping is uncertain beyond a budget
gareth-morgan-nv cc3b276
egocap: age the clock mapping's uncertainty at read, and state what t…
gareth-morgan-nv ca0a491
egocap: age the browser clock mapping at read; shared-truth timeline …
gareth-morgan-nv 486b6a5
egocap: stop tracking test recordings output
gareth-morgan-nv cae2d58
egocap: state that tracking uses the existing host poll timestamp
gareth-morgan-nv 2a8240f
egocap, sensing: record and show latency from the frame-ready time by…
gareth-morgan-nv 84afaa0
egocap: keep the clock-sync client importable without livekit
gareth-morgan-nv fa94818
egocap: describe the LiveKit timestamp bases and clock-sync recording…
gareth-morgan-nv 503353c
egocap: use a recordable timestamp basis in the budget tests and wrap…
gareth-morgan-nv 4828868
egocap: the tile's frame effect reads the current clock and basis
gareth-morgan-nv 77cb87b
egocap: a silent track's failure says what is wrong with the clock ma…
gareth-morgan-nv 2195846
egocap: tests follow the merged receiver and feed
gareth-morgan-nv 8b51685
egocap: address timing review findings
gareth-morgan-nv bd423a4
egocap: state what the clock-sync responders accept and bound
gareth-morgan-nv 0a4625a
egocap: drop two unused imports from the backend app
gareth-morgan-nv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # SPDX-FileCopyrightText: 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Maps a remote machine's monotonic clock onto this one's from four-timestamp exchanges. | ||
|
|
||
| The pattern is the one CloudXR uses for its clock sync: this side stamps ``t1`` when it sends a request, the remote | ||
| side stamps ``t2`` on receipt and ``t3`` when it replies, and this side stamps ``t4`` on the reply. The round trip | ||
| without the remote's processing time is ``(t4 - t1) - (t3 - t2)``, and the remote-minus-local offset is | ||
| ``((t2 - t1) + (t3 - t4)) / 2``. That point estimate assumes the two directions took equally long; the true offset | ||
| can lie anywhere within half the round trip of it, so that half is reported as the mapping's uncertainty and no | ||
| smaller bound is ever claimed. All times are integer nanoseconds and the offset is signed. | ||
|
|
||
| This module only turns exchanges into a mapping. Sending the requests, and the transport under them, belong to | ||
| the caller, and so does the policy for what to do when no mapping is available: a mapping that is missing, stale | ||
| or discontinuous means the remote's timestamps cannot be placed on this clock. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections import deque | ||
| from dataclasses import dataclass, replace | ||
|
|
||
| #: Exchanges whose round trip exceeds this are not used: a slow or queued exchange says little about the clocks. | ||
| DEFAULT_MAX_RTT_NS = 250_000_000 | ||
| #: A mapping older than this (by this side's clock) is stale and is withheld. | ||
| DEFAULT_STALE_AFTER_NS = 5_000_000_000 | ||
| #: The most recent accepted exchanges the best one is chosen from. | ||
| DEFAULT_WINDOW = 8 | ||
| #: Relative clock rate difference tolerated between two mappings before their offsets are judged discontinuous. | ||
| DEFAULT_DRIFT_ALLOWANCE = 200e-6 | ||
| #: Adopted mappings kept as evidence; the newest are the ones a take can be recorded under. | ||
| _MAX_HISTORY = 256 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Exchange: | ||
| """One completed four-timestamp exchange: ``t1``/``t4`` on the local clock, ``t2``/``t3`` on the remote's.""" | ||
|
|
||
| t1: int | ||
| t2: int | ||
| t3: int | ||
| t4: int | ||
|
|
||
| @property | ||
| def rtt_ns(self) -> int: | ||
| """Round trip with the remote's own processing time removed.""" | ||
| return (self.t4 - self.t1) - (self.t3 - self.t2) | ||
|
|
||
| @property | ||
| def offset_ns(self) -> int: | ||
| """Remote minus local, assuming symmetric transit.""" | ||
| return ((self.t2 - self.t1) + (self.t3 - self.t4)) // 2 | ||
|
|
||
| @property | ||
| def well_formed(self) -> bool: | ||
| """Time moved forward on both clocks and the round trip is not negative.""" | ||
| return self.t4 >= self.t1 and self.t3 >= self.t2 and self.rtt_ns >= 0 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ClockMapping: | ||
| """An immutable mapping of the remote clock onto the local one; frame conversion only reads this.""" | ||
|
|
||
| #: Starts at 1 and changes whenever the estimator starts over or finds a discontinuity. | ||
| epoch: int | ||
| offset_ns: int | ||
| rtt_ns: int | ||
| #: Half the round trip, plus the drift allowed over the exchange's age: the error unknown path asymmetry and | ||
| #: clock rate difference can cause. | ||
| uncertainty_ns: int | ||
| #: Local time at which the exchange behind this mapping completed. | ||
| accepted_at_ns: int | ||
|
|
||
| def to_local_ns(self, remote_ns: int) -> int: | ||
| """The local-clock time of a remote-clock reading.""" | ||
| return remote_ns - self.offset_ns | ||
|
|
||
|
|
||
| class ClockEstimator: | ||
| """Keeps the best recent exchange as the current mapping and notices when the clocks jump apart.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| max_rtt_ns: int = DEFAULT_MAX_RTT_NS, | ||
| stale_after_ns: int = DEFAULT_STALE_AFTER_NS, | ||
| window: int = DEFAULT_WINDOW, | ||
| drift_allowance: float = DEFAULT_DRIFT_ALLOWANCE, | ||
| ) -> None: | ||
| self._max_rtt_ns = max_rtt_ns | ||
| self._stale_after_ns = stale_after_ns | ||
| self._drift_allowance = drift_allowance | ||
| self._recent: deque[Exchange] = deque(maxlen=window) | ||
| self._epoch = 1 | ||
| self._current: ClockMapping | None = None | ||
| #: Local time of the newest accepted exchange: staleness is about whether replies are still arriving, not | ||
| #: about how old the best exchange in the window is. | ||
| self._newest_ns: int | None = None | ||
| #: Every mapping adopted, oldest first, so a take can record the epochs and quality it was made under. | ||
| self.history: list[ClockMapping] = [] # trimmed to the newest _MAX_HISTORY | ||
| #: Set, and left set, when a new best mapping disagrees with the last by more than their uncertainties and | ||
| #: the allowed drift explain: mappings before it stay as they were, and a take spanning it is not aligned. | ||
| self.discontinuity = False | ||
|
|
||
| def reset(self) -> None: | ||
| """Start over, for a new source session or a restarted remote: the old mapping no longer applies.""" | ||
| self._recent.clear() | ||
| self._current = None | ||
| self._newest_ns = None | ||
| self._epoch += 1 | ||
|
|
||
| def add(self, exchange: Exchange) -> str | None: | ||
| """Take one exchange into account; returns why it was rejected, or None when it was accepted.""" | ||
| if not exchange.well_formed: | ||
| return "malformed exchange" | ||
| if exchange.rtt_ns > self._max_rtt_ns: | ||
| return "round trip too long" | ||
| previous = self._current | ||
| self._newest_ns = exchange.t4 | ||
| newest = self._mapping_of(exchange) | ||
| if previous is not None and self._disagrees(previous, newest): | ||
| # Not a refinement: the clocks moved. Judged on the newest exchange, not on the best of the window, so | ||
| # a jump is seen at once even when old exchanges have an equally good round trip. Keep the evidence and | ||
| # start a new epoch from this exchange alone. | ||
| self.discontinuity = True | ||
| self._epoch += 1 | ||
| self._recent.clear() | ||
| self._recent.append(exchange) | ||
| candidate = self._mapping_of(exchange) | ||
| else: | ||
| self._recent.append(exchange) | ||
| # The exchange with the smallest uncertainty now: half its round trip plus the drift its age could have | ||
| # added. A low round trip beats a fresher one only while its age stays cheaper; the newest wins ties. | ||
| best = min(self._recent, key=lambda e: (self._uncertainty_ns(e, exchange.t4), -e.t4)) | ||
| candidate = self._mapping_of(best, exchange.t4) | ||
| if candidate != previous: | ||
| self._current = candidate | ||
| self.history.append(candidate) | ||
| del self.history[:-_MAX_HISTORY] | ||
| return None | ||
|
|
||
| def mapping(self, now_ns: int) -> ClockMapping | None: | ||
| """The current mapping, or None when there is none yet or no exchange arrived within the stale limit. | ||
|
|
||
| The uncertainty is aged to ``now_ns``: while replies are late the offset's drift allowance keeps growing, | ||
| so a budget check on a returned mapping never sees a figure older than the call. History is not appended. | ||
| """ | ||
| current = self._current | ||
| if current is None or self._newest_ns is None or now_ns - self._newest_ns > self._stale_after_ns: | ||
| return None | ||
| aged = current.rtt_ns // 2 + int(self._drift_allowance * max(0, now_ns - current.accepted_at_ns)) | ||
| if aged <= current.uncertainty_ns: | ||
| return current | ||
| return replace(current, uncertainty_ns=aged) | ||
|
|
||
| def _uncertainty_ns(self, exchange: Exchange, now_ns: int) -> int: | ||
| """Half the round trip plus the offset drift the exchange's age allows at the allowed relative rate.""" | ||
| return exchange.rtt_ns // 2 + int(self._drift_allowance * max(0, now_ns - exchange.t4)) | ||
|
|
||
| def _mapping_of(self, exchange: Exchange, now_ns: int | None = None) -> ClockMapping: | ||
| return ClockMapping( | ||
| epoch=self._epoch, | ||
| offset_ns=exchange.offset_ns, | ||
| rtt_ns=exchange.rtt_ns, | ||
| uncertainty_ns=self._uncertainty_ns(exchange, exchange.t4 if now_ns is None else now_ns), | ||
| accepted_at_ns=exchange.t4, | ||
| ) | ||
|
|
||
| def _disagrees(self, previous: ClockMapping, candidate: ClockMapping) -> bool: | ||
| elapsed_ns = max(0, candidate.accepted_at_ns - previous.accepted_at_ns) | ||
| allowed = previous.uncertainty_ns + candidate.uncertainty_ns + self._drift_allowance * elapsed_ns | ||
| return abs(candidate.offset_ns - previous.offset_ns) > allowed | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: NVIDIA/IsaacCapture
Length of output: 6965
Script executed:
Repository: NVIDIA/IsaacCapture
Length of output: 37220
Script executed:
Repository: NVIDIA/IsaacCapture
Length of output: 20287
Script executed:
Repository: NVIDIA/IsaacCapture
Length of output: 488
Count distinct mappings removed from history.
When history trimming removes a distinct mapping,
mappings_omitted_oldestdoes not count it. With regular replies, this can occur after roughly 256 exchanges. The saved evidence can then omit mappings used for early frames and under-report the omissions.Track removed mappings using the same
(epoch, accepted_at_ns)key as the evidence deduplication. A raw trimmed-entry count can overcount repeated reports.