From 60598acdee3fc0d3e25c36e0da878b8aff5bda0d Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 14:59:07 -0400 Subject: [PATCH] Add playback performance HUD View > Performance HUD (Ctrl+Alt+H) overlays measured FPS against target, frame, resolution, average decode cost and cache depth. It answers the question a reviewer actually asks when playback feels wrong: is it me, or is it the machine? A supervisor calling a note on timing needs to know they are watching 24 fps and not 17, or they are grading the playback and not the shot. playback/stats.py is pure and takes an injected clock, so the rolling averages are asserted exactly instead of being slept for. - Frame rate is measured where frames reach the viewer, which is the only honest definition of a displayed frame -- not at the playback timer, which keeps firing whether or not a frame made it. - Decode cost is timed around the decode itself. For sequences that is inside the decode thread, so the queue wait in front of it is not blamed on the decoder; for movies it covers the proxy scale, RGB conversion and OCIO, which is what stands between a packet and the screen. - A decode slower than its share of the frame budget is flagged: it cannot sustain real time however fast the rest of the pipeline is. A stall and a pause both stop frames arriving and both empty the rolling window, so measured FPS falls to zero, which is the same reading as playback never having started. Without telling those apart, a total freeze renders as a calm placeholder dash, and that is the exact failure this HUD exists to catch. stalled() tracks whether frames were ever flowing, and the window passes in whether the player is actually running, so a freeze reads STALLED in red while a pause stays quiet. Only a genuinely bad row is coloured, so the eye goes to the thing that is actually wrong rather than a wall of red. 26 tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- playback/player.py | 41 ++++++- playback/stats.py | 201 +++++++++++++++++++++++++++++++ tests/test_stats.py | 284 ++++++++++++++++++++++++++++++++++++++++++++ widgets/__init__.py | 68 +++++++++++ widgets/viewer.py | 76 ++++++++++++ 5 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 playback/stats.py create mode 100644 tests/test_stats.py diff --git a/playback/player.py b/playback/player.py index 1f2b071..b3f0fc8 100644 --- a/playback/player.py +++ b/playback/player.py @@ -55,6 +55,7 @@ from __future__ import absolute_import +import time import numpy import threading @@ -79,7 +80,8 @@ class SequenceDecodeThread(QtCore.QThread): """Bounded EXR/image decoder queue modeled after a media decoder FIFO.""" - decoded = QtCore.Signal(int, object, int, str) + # frame_number, image, generation, error, decode_milliseconds + decoded = QtCore.Signal(int, object, int, str, float) def __init__(self, parent=None): super().__init__(parent) @@ -145,6 +147,7 @@ def run(self): reader, frame_number, aov, processor, generation, _key = task error = "" image = None + started = time.perf_counter() try: image = reader.get_frame( frame_number, @@ -156,7 +159,10 @@ def run(self): finally: with self._condition: self._inflight_key = None - self.decoded.emit(frame_number, image, generation, error) + # Timed here rather than on the GUI thread: this is the decode, and + # the queue wait in front of it is not the decoder's fault. + decode_ms = (time.perf_counter() - started) * 1000.0 + self.decoded.emit(frame_number, image, generation, error, decode_ms) class BasePlayer(QtCore.QObject): @@ -215,6 +221,9 @@ def __init__(self): # Active player implementation. self.player = None + # Optional PlaybackStats shared with whichever implementation is live. + self.stats = None + # Active OCIO processor self.ocio_processor = None @@ -257,6 +266,10 @@ def load(self, path): else: self.player = SequencePlayer() + # load() rebuilds the implementation, so the stats object has to be + # re-attached or the HUD silently goes dead on the next source. + self.player.stats = self.stats + if self.ocio_processor: self.player.set_ocio(self.ocio_processor, self.input_space, self.display, self.view) @@ -355,6 +368,12 @@ def set_fps(self, fps): if self.player and hasattr(self.player, "set_fps"): self.player.set_fps(fps) + def set_stats(self, stats): + """Attach a PlaybackStats collector (None disables measurement).""" + self.stats = stats + if self.player is not None: + self.player.stats = stats + def set_aov(self, aov): """Set active AOV. @@ -454,6 +473,9 @@ def __init__(self): self.playback_direction = 1 self.is_playing = False + # Optional PlaybackStats, assigned by MediaPlayer when the HUD is on. + self.stats = None + # Active AOV self.current_aov = "rgb" @@ -922,7 +944,7 @@ def update_frame(self): priority=True, ) - def _frame_decoded(self, frame_number, frame, generation, error): + def _frame_decoded(self, frame_number, frame, generation, error, decode_ms=0.0): if generation != self.decode_generation or self.reader is None: return if error: @@ -930,6 +952,8 @@ def _frame_decoded(self, frame_number, frame, generation, error): return if frame is None: return + if self.stats is not None: + self.stats.record_decode(decode_ms) self.cache.add(frame_number, frame) self.cache_changed.emit(self.cache.cached_frames()) if frame_number == self.display_request_frame: @@ -1025,6 +1049,9 @@ def __init__(self): self.loop_enabled = False self.loop_mode = loopmode.OFF + # Optional PlaybackStats, assigned by MediaPlayer when the HUD is on. + self.stats = None + # Timeline start frame. self.start_frame = constants.VL_START_FRAME @@ -1434,6 +1461,8 @@ def display_video_frame(self, frame): ensuring accurate synchronization with playback time. """ + started = time.perf_counter() + frame_time = frame.time if frame_time is None and frame.pts is not None: frame_time = float(frame.pts * self.reader.video_stream.time_base) @@ -1479,6 +1508,12 @@ def display_video_frame(self, frame): min(frame_number, self.start_frame + self.frame_count - 1), ) + # The proxy scale, RGB conversion and OCIO transform are what stand + # between a decoded packet and the screen, so that is the cost the HUD + # reports for movies. + if self.stats is not None: + self.stats.record_decode((time.perf_counter() - started) * 1000.0) + # Send the image to the viewer. self.frame_ready.emit(image) diff --git a/playback/stats.py b/playback/stats.py new file mode 100644 index 0000000..c3d3882 --- /dev/null +++ b/playback/stats.py @@ -0,0 +1,201 @@ +"""Playback performance measurement for the viewer HUD. + +Answers the question a reviewer actually asks when playback feels wrong: *is it +me, or is it the machine?* A supervisor calling a note on timing needs to know +they are watching 24 fps and not 17 -- otherwise they are grading the playback, +not the shot. + +Everything here is pure. The clock is injected, so the rolling averages can be +tested exactly rather than by sleeping and hoping. +""" + +from __future__ import absolute_import + +import time + +from collections import deque + +# Frames are measured over a short trailing window: long enough to be stable, +# short enough that a stall shows up immediately rather than being averaged away. +DEFAULT_WINDOW_SECONDS = 1.0 + +# Decode timings are averaged over a fixed count instead of a time window, so a +# paused player still reports the cost of the frames it did decode. +DEFAULT_DECODE_SAMPLES = 30 + + +class PlaybackStats(object): + """Rolling measurement of displayed frame rate and decode cost. + + Example: + >>> stats = PlaybackStats() + >>> stats.record_frame() + >>> stats.measured_fps() + """ + + def __init__(self, clock=None, window=DEFAULT_WINDOW_SECONDS, + decode_samples=DEFAULT_DECODE_SAMPLES): + # Injected for tests; perf_counter is monotonic, unlike time(). + self.clock = clock or time.perf_counter + self.window = float(window) + + self.frame_times = deque() + self.decode_times = deque(maxlen=int(decode_samples)) + + self.dropped = 0 + + # Total frames seen since the last reset. Distinguishes "playback has + # not started" from "playback started and then died" -- both leave the + # rolling window empty, but only one of them is a problem. + self.frames_seen = 0 + + def reset(self): + """Forget every measurement (a new source is not the old one's tail).""" + self.frame_times.clear() + self.decode_times.clear() + self.dropped = 0 + self.frames_seen = 0 + + def record_frame(self): + """Record that a frame reached the screen.""" + now = self.clock() + self.frame_times.append(now) + self.frames_seen += 1 + self._trim(now) + + def record_decode(self, milliseconds): + """Record how long one frame took to decode.""" + try: + value = float(milliseconds) + except (TypeError, ValueError): + return + if value >= 0: + self.decode_times.append(value) + + def record_dropped(self, count=1): + """Record frames the player had to skip to keep up.""" + self.dropped += max(0, int(count)) + + def _trim(self, now): + threshold = now - self.window + while self.frame_times and self.frame_times[0] < threshold: + self.frame_times.popleft() + + def measured_fps(self): + """Return the displayed frame rate over the trailing window. + + Returns 0.0 until two frames have been seen -- one timestamp measures no + interval, and reporting a rate from it would be a guess. + """ + self._trim(self.clock()) + + if len(self.frame_times) < 2: + return 0.0 + + span = self.frame_times[-1] - self.frame_times[0] + if span <= 0: + return 0.0 + + # N timestamps bound N-1 intervals. + return (len(self.frame_times) - 1) / span + + def average_decode_ms(self): + """Return the mean decode time over the recent samples.""" + if not self.decode_times: + return 0.0 + return sum(self.decode_times) / len(self.decode_times) + + def stalled(self): + """True when frames were playing and then stopped arriving entirely. + + A hard stall empties the rolling window, so measured_fps() drops to + zero -- the same reading as "nothing has played yet". Without this + distinction a total freeze would render as a calm "--", which is exactly + the failure the HUD exists to catch. + """ + return self.frames_seen >= 2 and self.measured_fps() <= 0 + + def is_realtime(self, target_fps, tolerance=0.95): + """True when playback is holding *target_fps* (within tolerance). + + A player that has not shown two frames yet is not failing -- it just has + nothing to say -- so it reports True rather than alarming the reviewer. + A player that HAS played and then stopped is a different matter: see + :meth:`stalled`, which the HUD checks alongside this. + """ + measured = self.measured_fps() + if measured <= 0: + return True + + try: + target = float(target_fps) + except (TypeError, ValueError): + return True + + if target <= 0: + return True + + return measured >= target * tolerance + + +def hud_lines(stats, target_fps=0, playing=False, frame=None, frame_count=None, + resolution=None, proxy_label=None, cached=None): + """Format the HUD as a list of ``(label, value, ok)`` rows. + + ``ok`` is False only for a genuinely bad reading, so the HUD can colour just + that row rather than shouting about everything at once. + + ``playing`` is needed to tell a stall from a pause: both stop frames + arriving, but only one of them is a fault. + """ + rows = list() + + measured = stats.measured_fps() + + try: + target = float(target_fps or 0) + except (TypeError, ValueError): + target = 0.0 + + if playing and stats.stalled(): + # Frames were arriving and then stopped: say so loudly. + rows.append(("FPS", "STALLED", False)) + else: + if measured > 0 and target > 0: + fps_text = "{0:.1f} / {1:g}".format(measured, target) + elif measured > 0: + fps_text = "{0:.1f}".format(measured) + else: + fps_text = "--" + rows.append(("FPS", fps_text, stats.is_realtime(target))) + + if frame is not None: + total = "" if frame_count in (None, 0) else " / {0}".format(frame_count) + rows.append(("FRAME", "{0}{1}".format(frame, total), True)) + + if resolution: + text = "{0} x {1}".format(resolution[0], resolution[1]) + if proxy_label: + text = "{0} {1}".format(text, proxy_label) + rows.append(("RES", text, True)) + + decode = stats.average_decode_ms() + if decode > 0: + budget = (1000.0 / target) if target > 0 else 0 + # A frame that takes longer to decode than its share of the clock cannot + # sustain real time, no matter how fast the rest of the pipeline is. + rows.append( + ("DECODE", "{0:.1f} ms".format(decode), not budget or decode <= budget) + ) + + if cached is not None: + rows.append(("CACHE", "{0} frames".format(cached), True)) + + if stats.dropped: + rows.append(("DROPPED", str(stats.dropped), False)) + + return rows + + +if __name__ == "__main__": + pass diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..4da2af6 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,284 @@ +"""Tests for the playback performance HUD.""" + +import pytest + +from playback import stats as statsmath +from playback.stats import PlaybackStats + + +class _Clock: + """A hand-cranked clock, so rates are asserted exactly rather than slept for.""" + + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +def _stats(window=1.0): + clock = _Clock() + return PlaybackStats(clock=clock, window=window), clock + + +# --------------------------------------------------------------------------- # +# Measured frame rate +# --------------------------------------------------------------------------- # +def test_no_frames_reports_nothing_rather_than_zero_fps(): + stats, _clock = _stats() + + # Nothing has played; the HUD must not claim playback is broken. + assert stats.measured_fps() == 0.0 + assert stats.is_realtime(24) is True + + +def test_a_single_frame_is_not_a_rate(): + stats, _clock = _stats() + stats.record_frame() + + # One timestamp bounds no interval. Reporting a rate from it would be a + # fabrication. + assert stats.measured_fps() == 0.0 + + +def test_frames_at_24fps_measure_24fps(): + stats, clock = _stats() + + for _ in range(25): + stats.record_frame() + clock.advance(1 / 24.0) + + # 25 timestamps across 24 intervals of 1/24s. + assert stats.measured_fps() == pytest.approx(24.0, rel=1e-6) + + +def test_half_rate_playback_measures_half_rate(): + stats, clock = _stats(window=10.0) + + for _ in range(13): + stats.record_frame() + clock.advance(1 / 12.0) + + assert stats.measured_fps() == pytest.approx(12.0, rel=1e-6) + assert stats.is_realtime(24) is False # this is the whole point of the HUD + + +def test_old_frames_fall_out_of_the_window(): + stats, clock = _stats(window=1.0) + + for _ in range(10): + stats.record_frame() + clock.advance(0.01) + + # A stall: the window slides past every recorded frame. + clock.advance(5.0) + + assert stats.measured_fps() == 0.0 + + +def test_a_stall_is_visible_immediately_not_averaged_away(): + stats, clock = _stats(window=1.0) + + # A second of healthy 24 fps... + for _ in range(24): + stats.record_frame() + clock.advance(1 / 24.0) + assert stats.measured_fps() == pytest.approx(24.0, rel=0.05) + + # ...then a 2 second freeze. The window empties, so the measured rate falls + # to zero -- the same reading as "nothing has played yet". stalled() is what + # tells those two apart; without it a total freeze renders as a calm "--", + # which is the exact failure this HUD exists to catch. + clock.advance(2.0) + + assert stats.measured_fps() == 0.0 + assert stats.stalled() is True + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24, playing=True)) + assert rows["FPS"] == ("STALLED", False) + + +def test_a_pause_is_not_a_stall(): + """Paused playback stops frames arriving too, but it is not a fault.""" + stats, clock = _stats(window=1.0) + + for _ in range(24): + stats.record_frame() + clock.advance(1 / 24.0) + + clock.advance(5.0) # the reviewer hit pause and went for coffee + + assert stats.stalled() is True # the measurement cannot tell on its own... + + # ...so the HUD is told whether the player is actually running. + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24, playing=False)) + assert rows["FPS"] == ("--", True) + + +def test_a_fresh_player_is_not_stalled(): + stats, _clock = _stats() + + assert stats.stalled() is False + + stats.record_frame() + assert stats.stalled() is False # one frame is not "was playing" + + +def test_reset_forgets_everything(): + stats, clock = _stats() + for _ in range(5): + stats.record_frame() + clock.advance(0.04) + stats.record_decode(12.0) + stats.record_dropped(3) + + stats.reset() + + assert stats.measured_fps() == 0.0 + assert stats.average_decode_ms() == 0.0 + assert stats.dropped == 0 + + +# --------------------------------------------------------------------------- # +# Decode timing +# --------------------------------------------------------------------------- # +def test_decode_average(): + stats, _clock = _stats() + + for value in (10.0, 20.0, 30.0): + stats.record_decode(value) + + assert stats.average_decode_ms() == pytest.approx(20.0) + + +def test_decode_samples_are_bounded(): + stats = PlaybackStats(clock=_Clock(), decode_samples=3) + + for value in (100.0, 1.0, 2.0, 3.0): + stats.record_decode(value) + + # The 100 ms outlier has aged out; the HUD reflects recent behaviour. + assert stats.average_decode_ms() == pytest.approx(2.0) + + +@pytest.mark.parametrize("value", [None, "slow", -5]) +def test_junk_decode_timings_are_ignored(value): + stats, _clock = _stats() + stats.record_decode(value) + + assert stats.average_decode_ms() == 0.0 + + +# --------------------------------------------------------------------------- # +# is_realtime +# --------------------------------------------------------------------------- # +def test_realtime_allows_a_small_shortfall(): + stats, clock = _stats(window=10.0) + + # 23.5 fps against a 24 fps target is not a problem worth colouring red. + for _ in range(24): + stats.record_frame() + clock.advance(1 / 23.5) + + assert stats.is_realtime(24) is True + + +@pytest.mark.parametrize("target", [0, None, "", "unknown"]) +def test_realtime_survives_a_missing_target(target): + stats, clock = _stats() + for _ in range(5): + stats.record_frame() + clock.advance(0.5) + + # Without a usable target there is nothing to fail against. + assert stats.is_realtime(target) is True + + +# --------------------------------------------------------------------------- # +# HUD rows +# --------------------------------------------------------------------------- # +def _rows_by_label(rows): + return {label: (value, ok) for label, value, ok in rows} + + +def test_hud_reports_measured_against_target(): + stats, clock = _stats(window=10.0) + for _ in range(13): + stats.record_frame() + clock.advance(1 / 12.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["FPS"][0] == "12.0 / 24" + assert rows["FPS"][1] is False # flagged: playback is not holding up + + +def test_hud_with_no_playback_yet(): + stats, _clock = _stats() + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["FPS"] == ("--", True) + + +def test_hud_includes_frame_resolution_and_cache(): + stats, _clock = _stats() + + rows = _rows_by_label( + statsmath.hud_lines( + stats, + target_fps=24, + frame=42, + frame_count=120, + resolution=(2048, 1152), + proxy_label="2K", + cached=48, + ) + ) + + assert rows["FRAME"][0] == "42 / 120" + assert rows["RES"][0] == "2048 x 1152 2K" + assert rows["CACHE"][0] == "48 frames" + + +def test_hud_flags_a_decode_that_cannot_hold_the_frame_rate(): + stats, _clock = _stats() + + # 60 ms per frame against a 24 fps budget of ~41.7 ms: it cannot keep up, + # however fast the rest of the pipeline is. + stats.record_decode(60.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["DECODE"] == ("60.0 ms", False) + + +def test_hud_accepts_a_decode_inside_the_frame_budget(): + stats, _clock = _stats() + stats.record_decode(12.0) + + rows = _rows_by_label(statsmath.hud_lines(stats, target_fps=24)) + + assert rows["DECODE"] == ("12.0 ms", True) + + +def test_hud_omits_rows_it_has_nothing_to_say_about(): + stats, _clock = _stats() + + labels = [label for label, _value, _ok in statsmath.hud_lines(stats)] + + # No decode samples, no frame, no resolution, no cache, no drops. + assert labels == ["FPS"] + + +def test_hud_reports_dropped_frames_only_when_there_are_some(): + stats, _clock = _stats() + assert "DROPPED" not in _rows_by_label(statsmath.hud_lines(stats)) + + stats.record_dropped(4) + rows = _rows_by_label(statsmath.hud_lines(stats)) + + assert rows["DROPPED"] == ("4", False) diff --git a/widgets/__init__.py b/widgets/__init__.py index 5b6b8fe..3db4b61 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -33,6 +33,9 @@ import constants from utils import timecode + +from playback.stats import PlaybackStats +from playback import stats as statsmath from utils import notescsv from PySide6 import QtGui @@ -128,6 +131,11 @@ def __init__(self, parent=None, **kwargs): # During playlist playback this loops the whole edit, never one clip. self.loop_enabled = False self.loop_mode = "off" + + # Performance HUD. Stats are always collected (they cost a deque append + # per frame); only the drawing is gated on the toggle. + self.playback_stats = PlaybackStats() + self.stats_hud_enabled = False self._playlist_loading = False self.playlist_entries = list() self.playlist_entry_index = -1 @@ -321,6 +329,19 @@ def setupUi(self): self.viewframe.timelineToolbarLayout.trigger_timeline.connect(self.trigger_timeline) self.viewframe.timelineToolbarLayout.fps_chanaged.connect(self.update_fps) self.viewframe.timelineToolbarLayout.volume_changed.connect(self.player.volume_changed) + + # -------------------------------------------------------------------- + # Performance HUD + # -------------------------------------------------------------------- + self.player.set_stats(self.playback_stats) + # A frame reaching the viewer is the only honest definition of a + # displayed frame, so the rate is measured there and not at the timer. + self.player.frame_ready.connect( + lambda _image: self.playback_stats.record_frame() + ) + self.statsHudTimer = QtCore.QTimer(self) + self.statsHudTimer.setInterval(250) + self.statsHudTimer.timeout.connect(self.refresh_stats_hud) # Keyboard Shortcuts # Play / Pause self.playShortcut = QtGui.QShortcut(QtGui.QKeySequence("Space"), self) @@ -482,6 +503,11 @@ def setup_review_chrome(self): self.actionShotTimeline.setChecked(True) self.actionShotTimeline.toggled.connect(self.shotSequenceWidget.setVisible) view_menu.addAction(self.actionShotTimeline) + self.actionStatsHud = QtGui.QAction("Performance HUD", self, checkable=True) + self.actionStatsHud.setIcon(NamePixmapIcon("display")) + self.actionStatsHud.setShortcut(QtGui.QKeySequence("Ctrl+Alt+H")) + self.actionStatsHud.toggled.connect(self.set_stats_hud) + view_menu.addAction(self.actionStatsHud) self.actionRecaps = QtGui.QAction("Review Notes Panel", self, checkable=True) self.actionRecaps.setIcon(NamePixmapIcon("txt")) self.actionRecaps.toggled.connect(self.recapsWidget.set_current_recaps) @@ -1127,6 +1153,11 @@ def openMedia(self, filepath=None, add_to_playlist=True): # Persist the outgoing source's annotations before the viewer is wiped. self._save_current_notes() + + # Clear measurements BEFORE the new source loads. Loading displays its + # first frame synchronously, so resetting afterwards would throw away + # the very frame (and decode timing) that was just recorded. + self.playback_stats.reset() # The old source is no longer active. Clearing this before opening the # replacement prevents a failed import followed by app exit from # overwriting the outgoing shot's sidecar with an empty sketch. @@ -2195,6 +2226,43 @@ def set_loop_mode(self, mode): message = f"Playback mode: {label}" self.statusBar().showMessage(message, 4500) + def set_stats_hud(self, enabled): + """Show or hide the viewer performance HUD.""" + self.stats_hud_enabled = bool(enabled) + self.viewframe.viewer.set_stats_hud(self.stats_hud_enabled) + + if self.stats_hud_enabled: + self.refresh_stats_hud() + self.statsHudTimer.start() + else: + self.statsHudTimer.stop() + + def refresh_stats_hud(self): + """Rebuild the HUD rows from the current measurements.""" + if not self.stats_hud_enabled: + return + + image = self.viewframe.viewer.qimage + resolution = (image.width(), image.height()) if image is not None else None + + cached = None + implementation = self.player.player + if implementation is not None and hasattr(implementation, "cache"): + cached = len(implementation.cache.cached_frames()) + + rows = statsmath.hud_lines( + self.playback_stats, + target_fps=self._current_fps(), + # A pause stops frames arriving just like a stall does; only the + # player knows which of the two this is. + playing=bool(self.player.is_playing), + frame=self.viewframe.timeline.current_frame, + frame_count=self.player.frame_count, + resolution=resolution, + cached=cached, + ) + self.viewframe.viewer.set_stats_rows(rows) + def set_gamma_check(self, enabled): """Toggle temporary Y-drag gamma inspection in the viewer.""" enabled = bool(enabled) diff --git a/widgets/viewer.py b/widgets/viewer.py index 5153a5d..ab611b7 100644 --- a/widgets/viewer.py +++ b/widgets/viewer.py @@ -1270,6 +1270,10 @@ def __init__(self, parent=None): # untouched for caching, annotations, snapshots, and exports. self.gamma_check_enabled = False self.exposure_check_enabled = False + + # Performance HUD, driven by MainWindow from PlaybackStats. + self.stats_hud_enabled = False + self.stats_rows = list() self.display_gamma = 1.0 self.display_exposure = 0.0 self.channel_view = "RGB" @@ -1859,6 +1863,8 @@ def paintGL(self): ) if self.gamma_check_enabled or self.exposure_check_enabled: self._draw_display_adjustment_hud(painter) + if self.stats_hud_enabled: + self._draw_stats_hud(painter) painter.end() @staticmethod @@ -1874,6 +1880,76 @@ def _draw_compare_label(painter, x, y, text): metrics.elidedText(text, QtCore.Qt.TextElideMode.ElideMiddle, width - 18), ) + def set_stats_hud(self, enabled): + """Show or hide the performance HUD.""" + self.stats_hud_enabled = bool(enabled) + self.update() + + def set_stats_rows(self, rows): + """Set the HUD contents as (label, value, ok) rows and repaint.""" + self.stats_rows = list(rows or []) + if self.stats_hud_enabled: + self.update() + + def _draw_stats_hud(self, painter): + """Draw the performance HUD in the top-left of the viewer.""" + rows = self.stats_rows + if not rows: + return + + painter.save() + + font = QtGui.QFont("Consolas") + font.setStyleHint(QtGui.QFont.StyleHint.Monospace) + font.setPointSize(9) + painter.setFont(font) + + metrics = painter.fontMetrics() + label_width = max(metrics.horizontalAdvance(row[0]) for row in rows) + value_width = max(metrics.horizontalAdvance(row[1]) for row in rows) + + padding = 10 + gap = 14 + line_height = metrics.height() + 2 + + width = label_width + gap + value_width + padding * 2 + height = line_height * len(rows) + padding * 2 + + rect = QtCore.QRectF(12, 12, width, height) + painter.fillRect(rect, QtGui.QColor(18, 21, 24, 225)) + painter.setPen(QtGui.QPen(QtGui.QColor(224, 174, 74), 1.0)) + painter.drawRect(rect) + + y = rect.top() + padding + for label, value, ok in rows: + painter.setPen(QtGui.QColor(150, 158, 166)) + painter.drawText( + QtCore.QRectF(rect.left() + padding, y, label_width, line_height), + QtCore.Qt.AlignmentFlag.AlignVCenter + | QtCore.Qt.AlignmentFlag.AlignLeft, + label, + ) + + # Only a genuinely bad reading is coloured, so the eye goes straight + # to the row that is actually wrong. + painter.setPen( + QtGui.QColor(238, 238, 238) if ok else QtGui.QColor(255, 92, 92) + ) + painter.drawText( + QtCore.QRectF( + rect.left() + padding + label_width + gap, + y, + value_width, + line_height, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter + | QtCore.Qt.AlignmentFlag.AlignLeft, + value, + ) + y += line_height + + painter.restore() + def _draw_display_adjustment_hud(self, painter): """Draw a compact production-style status chip for image inspection.""" if self.gamma_check_enabled: