From 5ef4d955fb24e82b2db695f9dc5da71b4608c1ad Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 11:22:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=A7=E7=8E=87=E8=AF=BB=E6=95=B0=E6=94=B9?= =?UTF-8?q?=E4=B8=BA"=E9=87=87=E9=9B=86=E6=97=B6=E5=88=BB"=E7=9A=84?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=E7=8E=87=EF=BC=8C=E5=B9=B6=E5=9C=A8=E6=97=81?= =?UTF-8?q?=E8=BE=B9=E6=98=BE=E7=A4=BA=E6=AF=8F=E5=B8=A7=E8=80=97=E6=97=B6?= =?UTF-8?q?=20-=20What:=20sdk=5Fworker=20=E7=9A=84=E9=87=87=E9=9B=86?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E9=87=8C=E6=8A=8A=E5=B8=A7=E7=8E=87=E4=B8=8E?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=88=B3=E7=9A=84=E5=8F=96=E6=A0=B7=E7=82=B9?= =?UTF-8?q?=E4=BB=8E"=E7=AE=97=E5=AE=8C=E4=B9=8B=E5=90=8E"(t1)=20=E6=94=B9?= =?UTF-8?q?=E6=88=90"=E5=8F=96=E5=88=B0=E8=BF=99=E4=B8=80=E5=B8=A7?= =?UTF-8?q?=E7=9A=84=E6=97=B6=E5=88=BB"(t0)=E2=80=94=E2=80=94frame=5Ftimes?= =?UTF-8?q?=E3=80=81SIGPkt.mono=5Ft=EF=BC=88=E5=AE=9E=E6=97=B6=E5=88=86?= =?UTF-8?q?=E6=94=AF=EF=BC=89=E4=B8=8E=20last=5Fframe=5Fmono=EF=BC=88?= =?UTF-8?q?=E6=96=AD=E7=BA=BF=E5=88=A4=E5=AE=9A=EF=BC=89=E9=83=BD=E7=94=A8?= =?UTF-8?q?=20t0=EF=BC=8C=E6=89=80=E4=BB=A5=2016.7=20FPS=20=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=98=8E=E7=A1=AE=E8=A1=A8=E7=A4=BA"=E5=B8=A7?= =?UTF-8?q?=E6=98=AF=E4=BB=A5=E8=BF=99=E4=B8=AA=E9=80=9F=E7=8E=87=E5=88=B0?= =?UTF-8?q?=E8=BE=BE=E7=9A=84"=EF=BC=8C=E8=80=8C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E6=8A=8A=E6=B5=81=E6=B0=B4=E7=BA=BF=E8=80=97=E6=97=B6=E5=BD=93?= =?UTF-8?q?=E6=88=90=E5=B8=A7=E7=8E=87=EF=BC=9Bprocess=5Fms=20=E7=9A=84?= =?UTF-8?q?=E7=BB=88=E7=82=B9=E4=BB=8E=20process=5Fchain=20=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E6=94=B9=E5=88=B0=20read=5Fforces=5Ffrom=5Fsensor=20?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=EF=BC=88t=5Fdone=EF=BC=89=EF=BC=8C=E5=9B=A0?= =?UTF-8?q?=E6=AD=A4=E5=AE=83=E7=BB=88=E4=BA=8E=E6=B6=B5=E7=9B=96=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E5=B8=A7=E7=9A=84**=E5=85=A8=E9=83=A8**=E5=BC=80?= =?UTF-8?q?=E9=94=80=EF=BC=88=E6=AD=A4=E5=89=8D=E6=BC=8F=E6=8E=89=E5=8A=9B?= =?UTF-8?q?=E5=80=BC=E8=AF=BB=E5=8F=96=EF=BC=89=EF=BC=9Blast=5Fframe=5Fat?= =?UTF-8?q?=20=E4=BB=8D=E5=8F=96=20t1=EF=BC=88compute=20chain=20=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=EF=BC=89=E4=BD=9C=E4=B8=BA=E8=8A=82=E6=8B=8D=E7=9B=B8?= =?UTF-8?q?=E4=BD=8D=E5=8F=82=E8=80=83=EF=BC=8C=E8=A1=8C=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=EF=BC=88=E5=B8=A7=E7=8E=87=E6=B5=8B=E8=AF=95=E4=BB=8D?= =?UTF-8?q?=E7=84=B6=E9=80=9A=E8=BF=87=EF=BC=89=E3=80=82app=5Fwindow=20?= =?UTF-8?q?=E5=9C=A8=20=E8=A7=86=E5=9B=BE=E2=96=B8=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=B8=A7=E7=8E=87=20=E6=89=93=E5=BC=80=E6=97=B6=E6=8A=8A?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7=E8=80=97=E6=97=B6=E4=B8=80=E8=B5=B7=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=9C=A8=E8=BD=A8=E9=81=93=E6=91=98=E8=A6=81=E9=87=8C?= =?UTF-8?q?=EF=BC=9A"16.7=20FPS=20=C2=B7=2058=20ms"=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20=5Flatest=5Fprocess=5Fms=EF=BC=88=E9=9A=8F?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D/=E5=9B=9E=E6=94=BE=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=EF=BC=89=E3=80=82tests/test=5Fui=5Fsmoke.py=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=B8=80=E9=A1=B9=E6=96=AD=E8=A8=80=EF=BC=9A=E8=BD=A8?= =?UTF-8?q?=E9=81=93=E5=90=8C=E6=97=B6=E7=BB=99=E5=87=BA=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E4=B8=8E=E8=80=97=E6=97=B6=EF=BC=8C=E4=B8=94=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E8=80=97=E6=97=B6=E6=95=B0=E6=8D=AE=E6=97=B6=E4=BB=8D=E5=8F=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=80=9F=E7=8E=87=E3=80=82=20-=20Why:=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9C=8B=E5=88=B0=20Studio=20=E4=B8=8A?= =?UTF-8?q?=E6=96=B9=E6=98=BE=E7=A4=BA=2016.67=20fps=20=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=2030=EF=BC=8C=E9=97=AE=E6=98=AF=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=94=99=E4=BA=86=E3=80=82=E6=9F=A5=E8=AF=81?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=9A=E8=BF=99=E4=B8=AA=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E6=9D=A5=E8=87=AA=20packet.fps=EF=BC=8C=E6=98=AF=E9=87=87?= =?UTF-8?q?=E9=9B=86=E5=BE=AA=E7=8E=AF=E7=9C=9F=E5=AE=9E=E7=9A=84"?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=E7=8E=87"=EF=BC=8C=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E8=A1=8C=E6=95=B0=E4=B9=9F=E6=98=AF=E5=AE=83?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=8D=E6=98=AF=E7=95=8C=E9=9D=A2=20bug?= =?UTF-8?q?=EF=BC=9B=E4=BD=86=E5=AE=83=E6=9C=89=E4=B8=A4=E4=B8=AA=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E8=AE=A9=E8=BF=99=E4=B8=AA=E6=95=B0=E5=AD=97**?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=8D=95=E7=8B=AC=E5=88=A4=E8=AF=BB**?= =?UTF-8?q?=EF=BC=9A(1)=20=E5=8F=96=E6=A0=B7=E7=82=B9=E5=9C=A8=E7=AE=97?= =?UTF-8?q?=E5=AE=8C=E4=B9=8B=E5=90=8E=EF=BC=8C=E4=BA=8E=E6=98=AF=E5=AE=83?= =?UTF-8?q?=E6=8A=8A=E6=9C=AC=E6=9C=BA=E6=B5=81=E6=B0=B4=E7=BA=BF=E8=80=97?= =?UTF-8?q?=E6=97=B6=E6=B7=B7=E8=BF=9B=E4=BA=86"=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=B8=A7=E7=8E=87"=EF=BC=9B(2)=20=E6=97=81=E8=BE=B9=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=AF=8F=E5=B8=A7=E8=80=97=E6=97=B6=EF=BC=8C=E8=80=8C?= =?UTF-8?q?"16.7=20FPS"=E6=97=A2=E5=8F=AF=E8=83=BD=E6=98=AF=E6=91=84?= =?UTF-8?q?=E5=83=8F=E5=A4=B4=E5=8F=AA=E7=BB=99=2016.7=EF=BC=8C=E4=B9=9F?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E6=98=AF=E6=B5=81=E6=B0=B4=E7=BA=BF=E8=B7=9F?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A=2030=EF=BC=88=E5=AE=9E=E6=B5=8B=EF=BC=9A?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7=20=E2=89=A430=20ms=20=E6=97=B6=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E4=BB=8D=E8=83=BD=E8=B7=91=E6=BB=A1=2029.3=20fps?= =?UTF-8?q?=EF=BC=9B40/50/60/70=20ms=20=E5=88=86=E5=88=AB=E6=8E=89?= =?UTF-8?q?=E5=88=B0=2024.7/19.8/16.5/14.1=20fps=E2=80=94=E2=80=9416.67=20?= =?UTF-8?q?=E6=81=B0=E5=A5=BD=E5=AF=B9=E5=BA=94=E6=AF=8F=E5=B8=A7=E7=BA=A6?= =?UTF-8?q?=2060=20ms=EF=BC=89=E3=80=82=E6=9C=AC=E6=9C=BA=20app=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=88365x365=20=E6=8B=BC=E6=8E=A5?= =?UTF-8?q?=E3=80=81dis=5Fmode=3D3=EF=BC=89=E5=9C=A8=20480p=20=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E4=B8=8A=20process=5Fchain+=E5=8A=9B=E5=80=BC?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=8F=AA=E8=A6=81=20~10=20ms=EF=BC=88?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E7=BA=A6=20100=20fps=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=9B=A0=E6=AD=A4=E6=9C=AC=E6=AC=A1=E6=8A=A5=E7=9A=84=2016.67?= =?UTF-8?q?=20=E4=B8=8D=E5=8F=AF=E8=83=BD=E6=98=AF=E8=BF=99=E5=8F=B0?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=B8=8A=E8=BF=99=E4=B8=AA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=B5=81=E6=B0=B4=E7=BA=BF=E7=93=B6=E9=A2=88=EF=BC=9B?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=8F=AF=E8=83=BD=E5=BF=85=E9=A1=BB=E9=9D=A0?= =?UTF-8?q?=E8=80=97=E6=97=B6=E6=95=B0=E6=8D=AE=E5=8C=BA=E5=88=86=E3=80=82?= =?UTF-8?q?=20-=20Verification:=20pytest=20shared/tests/test=5Fworker=5Ffr?= =?UTF-8?q?ame=5Frate.py=204=20=E9=A1=B9=E5=85=A8=E8=BF=87=EF=BC=8830=20fp?= =?UTF-8?q?s=20=E8=AE=BE=E5=A4=87=E4=BB=8D=E8=A2=AB=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E5=9C=A8=2029.3=20fps=EF=BC=89=EF=BC=9Bstudio=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=2090=20passed=EF=BC=88=E6=96=B0=E5=A2=9E=201=20?= =?UTF-8?q?=E9=A1=B9=EF=BC=89=EF=BC=9Bui=5Fcheck=20554=20=E9=A1=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1=E8=B4=A5=EF=BC=9B=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20SDK=20=E5=A4=8D=E6=B5=8B=EF=BC=9A=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=BA=90=2055.4=20fps=20=E5=88=B0=E8=BE=BE=E7=8E=87=E3=80=81?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7=2018.2=20ms=EF=BC=88=E5=90=AB=E5=8A=9B?= =?UTF-8?q?=E5=80=BC=E8=AF=BB=E5=8F=96=EF=BC=89=E3=80=82=20-=20Not=20in=20?= =?UTF-8?q?this=20change:=20frames.csv=20=E4=BB=8D=E6=9C=AA=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20process=5Fms=EF=BC=88=E4=B8=8B=E6=AC=A1=E6=94=B9?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=A0=BC=E5=BC=8F=E3=80=81=E5=8A=A0=E6=9C=AA?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2=2017=20=E5=88=97=E6=97=B6=E4=B8=80=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=EF=BC=8C=E9=81=BF=E5=85=8D=E8=BF=9E=E5=81=9A=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=E7=89=88=E6=9C=AC=E5=8D=87=E7=BA=A7=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=9C=AA=E5=8A=A8=E8=8A=82=E6=8B=8D=E9=80=BB=E8=BE=91=E6=9C=AC?= =?UTF-8?q?=E8=BA=AB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/src/orisys_studio/app_window.py | 11 ++++++++ apps/studio/tests/test_ui_smoke.py | 23 ++++++++++++++++ .../acquisition/sdk_worker.py | 26 +++++++++++++++---- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 1af3ba4..c039080 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -197,6 +197,9 @@ class StudioMainWindow(QMainWindow): #: Was the most recent readout valid? ``None`` before the first sample. self._last_sample_valid: bool | None = None self._latest_fps: float | None = None + #: What the last frame cost to acquire and process, in milliseconds — the + #: other half of the frame-rate reading. + self._latest_process_ms: float | None = None #: Last coefficients file written by the calibration panel (not applied). self._last_calibration_export = "" self._depth_range_value: tuple[float, float] | None = None @@ -1357,6 +1360,11 @@ class StudioMainWindow(QMainWindow): detail = t("status.rail_video_name", name=Path(path).name) if self._show_fps and self._latest_fps is not None: fps = f"{self._latest_fps:.1f} FPS" + # The cost goes next to the rate because one without the other cannot + # be read: "16.7 FPS" is either a slow camera or a pipeline that + # cannot keep up, and only this number tells the two apart. + if self._latest_process_ms: + fps = f"{fps} · {self._latest_process_ms:.0f} ms" detail = f"{detail} · {fps}" if detail else fps self.toolbar.set_source_summary(detail) # The full path and the replay's notes do not fit on a 30 px rail, so @@ -2111,6 +2119,7 @@ class StudioMainWindow(QMainWindow): self._last_display_available = None self._last_sample_valid = None self._latest_fps = None + self._latest_process_ms = None self._depth_range_value = None self._last_depth_frame = None # The new session's store is cleared *before* the charts are attached to @@ -2197,6 +2206,7 @@ class StudioMainWindow(QMainWindow): if not keep_frame: self.arrow_panel.clear_preview(t("panels.arrow_empty")) self._latest_fps = None + self._latest_process_ms = None self._last_sample_valid = None self._last_display_fallback = None self._last_display_available = None @@ -2365,6 +2375,7 @@ class StudioMainWindow(QMainWindow): self._render_run_image(packet) if is_new: self._latest_fps = getattr(packet, "fps", None) + self._latest_process_ms = getattr(packet, "process_ms", None) # The rail carries the live telemetry (fps, validity, calibration) and # the record strip its own numbers. Both compare before repainting, so # this is safe on a 16 ms timer. diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index dcd48df..751ece1 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -2105,6 +2105,29 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window): _finish(window, stuck) +def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window): + """A frame rate alone cannot say whether we or the camera are the slow part. + + ``packet.process_ms`` is the whole per-frame cost, so the rail shows it next + to the rate: "16.7 FPS · 58 ms" means the loop cannot keep up, while + "16.7 FPS · 6 ms" means the camera is only delivering 16.7. + """ + window = make_window() + window._show_fps = True + window._latest_fps = 16.67 + window._latest_process_ms = 58.4 + window._refresh_status_rail() + text = window.toolbar.source_summary.text() + assert "16.7 FPS" in text, text + assert "58 ms" in text, text + + # Without a measured cost the rate is still shown, on its own. + window._latest_process_ms = None + window._refresh_status_rail() + assert window.toolbar.source_summary.text().strip().endswith("FPS") + window.close() + + # ------------------------------------------------------- plot settings dialog def test_the_chart_gear_opens_the_settings_dialog(qt_app, make_window): """The gear is the one place the filters can be changed from the UI.""" diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 983402b..5591490 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -735,6 +735,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): break t0 = time.perf_counter() + # ``t0`` is when this frame was *collected*: the device's own rate + # and the frame's acquisition time are measured from here, not + # from the end of its processing (that would report how long the + # pipeline took, dressed up as a frame rate). + # # ``CaptureSource.read_latest`` hands back the newest frame and, # until the pipeline produces another one, that **same** frame # again. Treating the repeat as a new sample doubled every @@ -772,7 +777,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): compute_error = "" # The frame's acquisition time, so an event that happens on this # frame is dated when it happened rather than when it was queued. - frame_mono = max(0.0, time.perf_counter() - self._mono_t0) + frame_mono = max(0.0, t0 - self._mono_t0) try: # Applying new coefficients is reported separately from the # compute chain: a rejected document is an operator error, @@ -797,7 +802,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): chain_depth, chain_error = process_chain(sensor) compute_error = compute_error or chain_error t1 = time.perf_counter() - frame_times.append(t1) + # The rate is measured between *collections* (``t0``), so it is + # the rate the frames arrived at. Measuring it after the compute + # chain instead made the rail report the loop's own cost as if it + # were the device's rate. + frame_times.append(t0) cutoff = t1 - FPS_WINDOW_S frame_times = [t for t in frame_times if t >= cutoff] # A *rate*, not a count: counting arrivals in a one-second window @@ -807,6 +816,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0 fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0 forces = read_forces_from_sensor(sensor, config_ref=mode.config_ref) + # Everything this frame cost, including the force readout — the + # number that says whether the loop can still keep up with the + # device, which the frame rate alone cannot. + t_done = time.perf_counter() invalid_reason = forces.get("invalid_reason") or compute_error raw_frame = _ensure_bgr(raw) # The frame the chain above actually consumed, kept as the @@ -825,10 +838,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): mono_t=( frame_id * frame_period if is_file - else max(0.0, t1 - self._mono_t0) + else max(0.0, t0 - self._mono_t0) ), fps=fps, - process_ms=(t1 - t0) * 1000.0, + process_ms=(t_done - t0) * 1000.0, normal_force=forces["normal_force"], shear_x=forces["shear_x"], shear_y=forces["shear_y"], @@ -857,7 +870,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._emit(packet) frame_id += 1 saw_frame = True - last_frame_mono = t1 + last_frame_mono = t0 + # The pacing phase reference stays the end of the compute chain: + # the loop waits out the rest of the frame period from there, and + # that is the behaviour the frame-rate tests pin. last_frame_at = t1 except Exception as exc: self._set_end_reason("error")