From a1314a02cb63dd2f65f83f9e3193b7909d9866b9 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 12:07:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=98=BE=E7=A4=BA=E6=AF=8F?= =?UTF-8?q?=E5=B8=A7=E8=80=97=E6=97=B6=E4=B8=8E=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E8=87=AA=E8=BA=AB=E5=B8=A7=E7=8E=87=EF=BC=8C=E8=AE=A9=E4=BD=8E?= =?UTF-8?q?=E5=B8=A7=E7=8E=87=E5=8F=AF=E5=BD=92=E5=9B=A0=20-=20What:=20Sdk?= =?UTF-8?q?Packet=20=E6=96=B0=E5=A2=9E=20capture=5Ffps=EF=BC=88=E9=87=87?= =?UTF-8?q?=E9=9B=86=E6=BA=90=E8=87=AA=E6=8A=A5=E7=9A=84=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E9=80=9F=E7=8E=87=EF=BC=8C=E4=B8=8E=20fps=20=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=EF=BC=89=E4=B8=8E=20capture=5Fdropped?= =?UTF-8?q?=EF=BC=88SDK=20=E5=90=8E=E5=8F=B0=E8=AF=BB=E5=8F=96=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E7=B4=AF=E8=AE=A1=E7=9A=84"=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E4=BA=86=E4=BD=86=E6=B2=A1=E4=BA=BA=E5=8F=96=E8=B5=B0"?= =?UTF-8?q?=E7=9A=84=E5=B8=A7=E6=95=B0=EF=BC=89=EF=BC=9Bsdk=5Fworker=20?= =?UTF-8?q?=E7=9A=84=E5=BE=AA=E7=8E=AF=E8=AE=B0=E5=BD=95=20(t0,=20?= =?UTF-8?q?=E5=B7=B2=E5=8F=91=E5=B8=83=E5=BA=8F=E5=8F=B7)=20=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E6=AF=8F=E5=B8=A7=E7=AE=97=E5=87=BA=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E8=87=AA=E8=BA=AB=E7=9A=84=E5=8F=91=E5=B8=83=E9=80=9F?= =?UTF-8?q?=E7=8E=87=EF=BC=8C=E5=90=8C=E6=97=B6=E8=AF=BB=20dropped=5Fframe?= =?UTF-8?q?s=EF=BC=9B=E6=96=B0=E5=A2=9E=20=5Fcapture=5Fsource()/=5Fcapture?= =?UTF-8?q?=5Fdropped()=20=E4=B8=A4=E4=B8=AA=E5=8F=96=E5=80=BC=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=EF=BC=88=E5=8E=9F=20=5Fcapture=5Fsequence=20=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E5=8F=96=E5=80=BC=E9=80=BB=E8=BE=91=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E5=A4=8D=E7=94=A8=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?=E8=BF=99=E4=B8=80=E6=AE=B5=E4=B8=80=E8=B5=B7=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=9C=A8=E8=BD=A8=E9=81=93=E6=91=98=E8=A6=81=E4=B8=8A=EF=BC=9A?= =?UTF-8?q?`16.7=20FPS=20=C2=B7=2012=20ms=20=C2=B7=20=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=2030.0=20(-13)`=E2=80=94=E2=80=94=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E6=98=AF=E6=88=91=E4=BB=AC=E6=B6=88=E8=B4=B9=E7=9A=84=E3=80=81?= =?UTF-8?q?=E8=80=97=E6=97=B6=E6=98=AF=E6=AF=8F=E5=B8=A7=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E5=BC=80=E9=94=80=E3=80=81=E6=91=84=E5=83=8F=E5=A4=B4=E9=80=9F?= =?UTF-8?q?=E7=8E=87=E6=98=AF=E9=87=87=E9=9B=86=E6=BA=90=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=9A=84=EF=BC=9B=E4=B8=A4=E4=B8=AA=E9=80=9F?= =?UTF-8?q?=E7=8E=87=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6=E5=90=8E=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=20(-N)=20=E5=B0=B1=E6=98=AF"=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E6=9C=89=E3=80=81=E6=88=91=E4=BB=AC=E6=B2=A1=E6=8E=A5?= =?UTF-8?q?=E4=BD=8F"=E7=9A=84=E5=B8=A7=E6=95=B0=E3=80=82=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20locale=20=E9=94=AE=20status.rail=5Fcamera=5Ffps?= =?UTF-8?q?=EF=BC=88=E4=B8=AD=E8=8B=B1=EF=BC=89=E3=80=82tests/test=5Fworke?= =?UTF-8?q?r=5Fframe=5Frate.py=EF=BC=9A=E5=81=87=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=9A=84=E9=87=87=E9=9B=86=E6=BA=90=E7=8E=B0=E5=9C=A8=E4=B9=9F?= =?UTF-8?q?=E5=83=8F=20orisys.capture.source=20=E4=B8=80=E6=A0=B7=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1"=E5=8F=91=E5=B8=83=E6=97=B6=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E5=B8=A7=E8=BF=98=E6=B2=A1=E8=A2=AB=E5=8F=96=E8=B5=B0"?= =?UTF-8?q?=E7=9A=84=E4=B8=A2=E5=B8=A7=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BB=99=20get=5Fimg=20=E5=8A=A0=E4=B8=80=E4=B8=AA=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9A=84=E6=AF=8F=E5=B8=A7=E8=80=97=E6=97=B6?= =?UTF-8?q?=EF=BC=88PROCESS=5FCOST=5FS=EF=BC=89=EF=BC=9B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20test=5Fthe=5Fcamera=5Fs=5Fown=5Frate=5Fis=5Freported=5Fnext?= =?UTF-8?q?=5Fto=5Fours=EF=BC=9A=E5=BE=AA=E7=8E=AF=E8=B7=9F=E5=BE=97?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E6=91=84=E5=83=8F=E5=A4=B4=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E2=89=8830=E3=80=81=E4=B8=A2=E5=B8=A7=200=EF=BC=9B=E6=8A=8A?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E6=94=BE=E5=88=B0=2060=20ms/?= =?UTF-8?q?=E5=B8=A7=E5=90=8E=E6=88=91=E4=BB=AC=E7=9A=84=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E6=8E=89=E5=88=B0=2022=20=E4=BB=A5=E4=B8=8B=E3=80=81**?= =?UTF-8?q?=E6=91=84=E5=83=8F=E5=A4=B4=E9=80=9F=E7=8E=87=E4=BB=8D=E6=8A=A5?= =?UTF-8?q?=2030**=E3=80=81=E4=B8=A2=E5=B8=A7=20>5=E3=80=82=20-=20Why:=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=8A=A5"=E4=B8=8A=E6=96=B9=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=2016.7=20fps=20=E8=80=8C=E4=B8=8D=E6=98=AF=E5=AE=9E?= =?UTF-8?q?=E9=99=85=2030"=EF=BC=8C=E5=B9=B6=E4=B8=94=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E4=BA=86=E5=85=B3=E9=94=AE=E6=95=B0=E6=8D=AE=EF=BC=9A16.7=20fp?= =?UTF-8?q?s=20=E4=BD=86=E6=AF=8F=E5=B8=A7=E5=8F=AA=E8=80=97=2012=20ms?= =?UTF-8?q?=E3=80=8212=20ms=20=E7=9A=84=E5=B7=A5=E4=BD=9C=E9=87=8F?= =?UTF-8?q?=E5=A1=9E=E8=BF=9B=2060=20ms=20=E7=9A=84=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E9=87=8C=EF=BC=8C=E8=AF=B4=E6=98=8E=E5=BE=AA=E7=8E=AF=E5=A4=A7?= =?UTF-8?q?=E9=83=A8=E5=88=86=E6=97=B6=E9=97=B4=E5=9C=A8=E7=AD=89=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=80=8C"=E7=AD=89"=E6=9C=89=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E4=B8=8D=E5=90=8C=E7=9A=84=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=EF=BC=9A(a)=20=E6=91=84=E5=83=8F=E5=A4=B4/=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E5=8F=AA=E5=8F=91=E5=B8=83=2016.7=EF=BC=88USB=20=E5=B8=A6?= =?UTF-8?q?=E5=AE=BD=E3=80=81MJPG/YUYV=E3=80=81=E6=A0=BC=E5=BC=8F=E5=8D=8F?= =?UTF-8?q?=E5=95=86=EF=BC=89=EF=BC=8C(b)=20=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E5=8F=91=E5=B8=83=2030=20=E8=80=8C=E6=88=91=E4=BB=AC=E5=8F=AA?= =?UTF-8?q?=E5=8F=96=E5=88=B0=E4=B8=80=E5=8D=8A=EF=BC=88=E8=8A=82=E6=8B=8D?= =?UTF-8?q?=E4=B8=8E=E8=BD=AE=E8=AF=A2=E9=97=AE=E9=A2=98=EF=BC=89=E3=80=82?= =?UTF-8?q?=E5=8E=9F=E6=9D=A5=E7=9A=84=E8=AF=BB=E6=95=B0=E5=8F=AA=E6=9C=89?= =?UTF-8?q?"=E6=88=91=E4=BB=AC=E6=B6=88=E8=B4=B9=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E5=B0=91"=EF=BC=8C=E4=B8=A4=E7=A7=8D=E6=83=85=E5=BD=A2?= =?UTF-8?q?=E9=95=BF=E5=BE=97=E4=B8=80=E6=A8=A1=E4=B8=80=E6=A0=B7=EF=BC=8C?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=88=A4=E6=96=AD=EF=BC=8C=E6=89=80=E4=BB=A5?= =?UTF-8?q?=E8=BF=99=E4=B8=8D=E6=98=AF=E6=98=BE=E7=A4=BA=20bug=20=E8=80=8C?= =?UTF-8?q?=E6=98=AF**=E8=AF=BB=E6=95=B0=E7=BC=BA=E4=B8=80=E5=8D=8A**?= =?UTF-8?q?=E3=80=82SDK=20=E7=9A=84=20status.last=5Fseq=20=E4=B8=8E=20drop?= =?UTF-8?q?ped=5Fframes=20=E6=AD=A3=E5=A5=BD=E6=8F=90=E4=BE=9B=E5=8F=A6?= =?UTF-8?q?=E4=B8=80=E5=8D=8A=EF=BC=88last=5Fseq=20=E6=AF=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=B8=80=E5=B8=A7=E5=B0=B1=E8=87=AA=E5=A2=9E=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E6=B6=88=E8=B4=B9=E8=80=85=E6=97=A0=E5=85=B3=EF=BC=9B?= =?UTF-8?q?dropped=5Fframes=20=E5=9C=A8=E5=8F=91=E5=B8=83=E6=97=B6?= =?UTF-8?q?=E8=8B=A5=E4=B8=8A=E4=B8=80=E5=B8=A7=E4=BB=8D=E5=9C=A8=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E9=87=8C=E5=B0=B1=20+1=EF=BC=8C=E5=8D=B3=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E8=80=85=E7=9A=84=E4=BA=8F=E6=AC=A0=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=9B=A0=E6=AD=A4=E6=8A=8A=E5=AE=83=E8=AF=BB=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=E5=88=A4=E5=AE=9A=E3=80=82=20-=20Verificatio?= =?UTF-8?q?n:=20shared=20=E5=85=A8=E9=87=8F=20160=20passed=EF=BC=88?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=202=20=E9=A1=B9=EF=BC=8C=E5=90=AB=E4=B8=8A?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E5=BD=92=E5=9B=A0=E6=B5=8B=E8=AF=95=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bstudio=2090=20passed=EF=BC=9Bui=5Fcheck=20554=20?= =?UTF-8?q?=E9=A1=B9=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1=E8=B4=A5=EF=BC=9Blo?= =?UTF-8?q?cale=5Fcheck=20287=20=E5=AD=97=E9=9D=A2=E9=94=AE=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=91=BD=E4=B8=AD=E3=80=81=E4=B8=AD=E8=8B=B1=E9=94=AE?= =?UTF-8?q?=E9=9B=86=E4=B8=80=E8=87=B4=E3=80=82=E8=BF=87=E7=A8=8B=E4=B8=AD?= =?UTF-8?q?=E8=87=AA=E6=9F=A5=E5=87=BA=E4=B8=80=E4=B8=AA=E6=88=91=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E5=BC=95=E5=85=A5=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9A?= =?UTF-8?q?=E6=9C=80=E5=88=9D=E6=8A=8A=20(t0,=20=E5=BA=8F=E5=8F=B7)=20?= =?UTF-8?q?=E7=9A=84=E8=AE=B0=E5=BD=95=E4=B8=8E=E6=8C=89=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E6=94=BE=E5=9C=A8**=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2**=E9=87=8C=EF=BC=8C=E8=80=8C=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E6=9C=9F=E9=97=B4=E8=AF=A5=E5=88=86=E6=94=AF=E6=AF=8F?= =?UTF-8?q?=E7=A7=92=E4=BC=9A=E8=BF=9B=E5=87=A0=E4=B8=87=E6=AC=A1=EF=BC=8C?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8E=A8=E5=AF=BC=E6=8A=8A=20GIL=20=E6=8A=A2?= =?UTF-8?q?=E5=85=89=EF=BC=8C=E5=81=87=E6=91=84=E5=83=8F=E5=A4=B4=E7=9A=84?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=80=9F=E7=8E=87=E4=BB=8E=2030=20=E6=8E=89?= =?UTF-8?q?=E5=88=B0=208=EF=BC=88test=5Fthe=5Fsame=5Fframe=5Fis=5Fnot=5Fco?= =?UTF-8?q?unted=5Ftwice=20=E7=AB=8B=E5=88=BB=E5=8F=98=E7=BA=A2=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=94=B9=E4=B8=BA"=E4=BB=85=E5=9C=A8?= =?UTF-8?q?=E8=AF=A5=E5=BA=8F=E5=8F=B7=E5=8F=91=E7=94=9F=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=97=B6=E8=AE=B0=E5=BD=95"=EF=BC=8C=E5=8D=B3=E4=B8=BA?= =?UTF-8?q?=E6=AF=8F=E5=8F=91=E5=B8=83=E5=B8=A7=20O(1)=20=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E6=AF=8F=E8=BD=AE=E8=AF=A2=20O(n)=E3=80=82=20-=20Not?= =?UTF-8?q?=20in=20this=20change:=20=E6=9C=AA=E6=94=B9=E8=8A=82=E6=8B=8D/?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E9=80=BB=E8=BE=91=EF=BC=88=E5=81=87=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E4=B8=8A=2030=20fps=20=E4=B8=8E=2060=20ms=20=E6=85=A2?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E4=B8=A4=E7=A7=8D=E6=83=85=E5=BD=A2?= =?UTF-8?q?=E7=9A=84=E6=B6=88=E8=B4=B9=E9=80=9F=E7=8E=87=E9=83=BD=E7=AC=A6?= =?UTF-8?q?=E5=90=88=E9=A2=84=E6=9C=9F=EF=BC=8C=E6=9C=AA=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E8=8A=82=E6=8B=8D=20bug=EF=BC=89=EF=BC=9Bframes.csv=20?= =?UTF-8?q?=E4=BB=8D=E6=9C=AA=E8=AE=B0=E5=BD=95=20process=5Fms/capture=5Ff?= =?UTF-8?q?ps=EF=BC=88=E4=B8=8B=E6=AC=A1=E6=94=B9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=97=B6=E4=B8=80=E5=B9=B6=E5=8A=A0=EF=BC=89?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/config/locales/en_US.json | 1 + apps/studio/config/locales/zh_CN.json | 1 + apps/studio/src/orisys_studio/app_window.py | 19 ++++++ .../orisys_app_shared/acquisition/models.py | 6 ++ .../acquisition/sdk_worker.py | 57 +++++++++++++++++- shared/tests/test_worker_frame_rate.py | 58 ++++++++++++++++++- 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index c09a113..bcd5cbd 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -356,6 +356,7 @@ "samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)", "run_failed": "Run failed: {error}", "filters_saved": "Filter settings saved: {describe} (applies to the next session)", + "rail_camera_fps": "camera {fps}", "video_ended": "Video ended", "camera_disconnected": "Camera disconnected", "reset_ok": "Tracker reset", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 54d344b..993af71 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -356,6 +356,7 @@ "samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)", "run_failed": "启动失败: {error}", "filters_saved": "滤波设置已保存:{describe}(下次采集生效)", + "rail_camera_fps": "摄像头 {fps}", "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", "reset_ok": "追踪器已重置", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index c039080..f633515 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -200,6 +200,10 @@ class StudioMainWindow(QMainWindow): #: 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 + #: The capture source's own published rate and shortfall, over the same + #: window: together with the above they say who is the slow part. + self._latest_capture_fps: float = 0.0 + self._latest_capture_dropped: int = 0 #: Last coefficients file written by the calibration panel (not applied). self._last_calibration_export = "" self._depth_range_value: tuple[float, float] | None = None @@ -1365,6 +1369,13 @@ class StudioMainWindow(QMainWindow): # 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" + # ...and the camera's own rate settles it: a capture rate well above + # what we consumed means the frames were there and we did not take + # them (the drop counter says how many). + if self._latest_capture_fps: + fps = f"{fps} · {t('status.rail_camera_fps', fps=f'{self._latest_capture_fps:.1f}')}" + if self._latest_capture_dropped: + fps = f"{fps} (-{self._latest_capture_dropped})" 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 @@ -2120,6 +2131,8 @@ class StudioMainWindow(QMainWindow): self._last_sample_valid = None self._latest_fps = None self._latest_process_ms = None + self._latest_capture_fps = 0.0 + self._latest_capture_dropped = 0 self._depth_range_value = None self._last_depth_frame = None # The new session's store is cleared *before* the charts are attached to @@ -2207,6 +2220,8 @@ class StudioMainWindow(QMainWindow): self.arrow_panel.clear_preview(t("panels.arrow_empty")) self._latest_fps = None self._latest_process_ms = None + self._latest_capture_fps = 0.0 + self._latest_capture_dropped = 0 self._last_sample_valid = None self._last_display_fallback = None self._last_display_available = None @@ -2376,6 +2391,10 @@ class StudioMainWindow(QMainWindow): if is_new: self._latest_fps = getattr(packet, "fps", None) self._latest_process_ms = getattr(packet, "process_ms", None) + self._latest_capture_fps = float(getattr(packet, "capture_fps", 0.0) or 0.0) + self._latest_capture_dropped = int( + getattr(packet, "capture_dropped", 0) or 0 + ) # 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/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py index 50d7b88..1b9127b 100644 --- a/shared/src/orisys_app_shared/acquisition/models.py +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -41,6 +41,12 @@ class SdkPacket: timestamp: float fps: float process_ms: float + #: The rate the *capture source* published frames at, over the same window as + #: ``fps``. ``fps`` is what this loop consumed; when the two disagree, the + #: difference is frames the camera had and the loop did not take. + capture_fps: float = 0.0 + #: How many published frames were replaced before anyone consumed them. + capture_dropped: int = 0 normal_force: float = 0.0 shear_x: float = 0.0 shear_y: float = 0.0 diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 5591490..b43b8a7 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -123,9 +123,7 @@ def _capture_sequence(sensor) -> Optional[int]: every call decodes the next frame, so its counter says nothing about repeats and is ignored (``None``). """ - source = getattr(sensor, "capture_source", None) - if source is None: - source = getattr(sensor, "_capture_source", None) + source = _capture_source(sensor) if source is None or not bool(getattr(source, "running", False)): return None status = getattr(source, "status", None) @@ -135,6 +133,28 @@ def _capture_sequence(sensor) -> Optional[int]: return None +def _capture_dropped(sensor) -> Optional[int]: + """Frames the capture source published that nobody took in time. + + The SDK's background reader counts one of these every time it replaces a + frame that was never consumed, so this is the consumer's shortfall — the + number that says "the camera had it and we did not take it". + """ + source = _capture_source(sensor) + status = getattr(source, "status", None) if source is not None else None + try: + return int(getattr(status, "dropped_frames")) + except (TypeError, ValueError): + return None + + +def _capture_source(sensor): + source = getattr(sensor, "capture_source", None) + if source is None: + source = getattr(sensor, "_capture_source", None) + return source + + def process_chain(sensor) -> tuple[Optional[np.ndarray], str]: """Run the live processing chain for one already-read frame. @@ -714,6 +734,13 @@ class SdkAcquisitionWorker(RecordSinkMixin): #: When that frame arrived, and how far apart this device's frames are #: meant to be: the wait below never steps over the next frame's arrival. last_frame_at = 0.0 + #: ``(collection time, published sequence)`` over the same window as + #: ``frame_times``. The sequence advances whenever the *capture source* + #: publishes, whether or not this loop takes the frame, so comparing the + #: two rates says whether a low frame rate is the camera's or ours. + source_times: list[tuple[float, int]] = [] + source_dropped = 0 + last_source_seq: Optional[int] = None frame_period = 1.0 / max(1.0, float(mode.fps or 30.0)) # Per-session monotonic frame index: the join key shared by the video, # the CSV and the calibration records. @@ -748,6 +775,17 @@ class SdkAcquisitionWorker(RecordSinkMixin): # without a background reader (a video file) decodes a new frame # per call, so it has no repeat marker and is left alone. delivered = _capture_sequence(sensor) + if delivered is not None and delivered != last_source_seq: + # Bookkeeping per *published* frame, not per poll: this branch + # is entered many times while waiting, and a list comprehension + # on every one of them starves the capture thread of the GIL + # (measured: the fake camera's own 30 fps dropped to 8). + last_source_seq = delivered + source_times.append((t0, delivered)) + source_cutoff = t0 - FPS_WINDOW_S + source_times = [ + item for item in source_times if item[0] >= source_cutoff + ] if delivered is not None and delivered == last_delivered: # Wait for the next frame, but never *past* the moment it is # due: sleeping a whole timer tick before looking again is how @@ -820,6 +858,17 @@ class SdkAcquisitionWorker(RecordSinkMixin): # number that says whether the loop can still keep up with the # device, which the frame rate alone cannot. t_done = time.perf_counter() + # What the capture source published over the window, so a rate + # below the camera's own can be attributed instead of guessed. + capture_fps = 0.0 + if len(source_times) > 1: + source_span = source_times[-1][0] - source_times[0][0] + published = source_times[-1][1] - source_times[0][1] + if source_span > 0.0: + capture_fps = max(0.0, published / source_span) + taken = _capture_dropped(sensor) + if taken is not None: + source_dropped = taken invalid_reason = forces.get("invalid_reason") or compute_error raw_frame = _ensure_bgr(raw) # The frame the chain above actually consumed, kept as the @@ -842,6 +891,8 @@ class SdkAcquisitionWorker(RecordSinkMixin): ), fps=fps, process_ms=(t_done - t0) * 1000.0, + capture_fps=capture_fps, + capture_dropped=source_dropped, normal_force=forces["normal_force"], shear_x=forces["shear_x"], shear_y=forces["shear_y"], diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index 14fc06c..18bcf8d 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -38,7 +38,9 @@ class _CaptureSource: The real one is a thread too: the sequence advances whether or not the consumer asks for a frame, which is exactly why an unchanged sequence means - "no new frame" rather than "the consumer has not called yet". + "no new frame" rather than "the consumer has not called yet". It also counts + a drop whenever it replaces a frame nobody took, like + ``orisys.capture.source.CaptureSource._background_loop`` does. """ def __init__(self, period_s: float = FRAME_PERIOD_S) -> None: @@ -46,6 +48,7 @@ class _CaptureSource: self.running = True self._period = float(period_s) self._stop = threading.Event() + self._taken_seq = 0 self._thread = threading.Thread(target=self._publish, daemon=True) self._thread.start() @@ -53,14 +56,25 @@ class _CaptureSource: while not self._stop.is_set(): if self._stop.wait(timeout=self._period): return + # Replacing a frame that was never consumed is a drop on our side. + if self.status.last_seq > self._taken_seq: + self.status.dropped_frames += 1 self.status.last_seq += 1 + def take(self) -> None: + self._taken_seq = self.status.last_seq + def close(self) -> None: self.running = False self._stop.set() self._thread.join(timeout=1.0) +#: Per-frame processing cost the fake device charges, so a test can make the +#: pipeline slower than the camera (the real box chain costs ~10 ms at 480p). +PROCESS_COST_S = 0.0 + + class _Info: """The channel names the worker reads off ``sensor.info``.""" @@ -105,7 +119,11 @@ class _Sensor: def get_img(self): # ``read_latest``: hand back the newest frame immediately, the same one - # again when nothing new has arrived. + # again when nothing new has arrived. A test can make this cost time, so + # the loop can be put under a pipeline slower than the camera. + if PROCESS_COST_S > 0: + time.sleep(PROCESS_COST_S) + self.capture_source.take() return self.frame def disconnect(self): @@ -211,6 +229,42 @@ def test_the_reported_rate_is_the_delivered_rate(): assert abs(settled[-1] - measured) < 0.25 * measured, (settled[-1], measured) +def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch): + """A low frame rate must be attributable, not just visible. + + With a pipeline that fits the frame period the two rates agree. With one that + does not, the *camera's* rate stays at 30 while ours falls and the source + counts the frames nobody took — which is the whole difference between "the + camera is slow" and "we are slow". + """ + packets, _ = _collect(duration_s=1.5) + assert packets, "the loop emitted nothing at all" + camera = [float(packet.capture_fps) for packet in packets if packet.capture_fps] + assert camera, "no packet carried the capture source's own rate" + assert 28.0 <= camera[-1] <= 31.0, f"the fake camera publishes at 30 fps, got {camera[-1]}" + assert packets[-1].capture_dropped == 0, ( + f"the loop dropped {packets[-1].capture_dropped} frames while keeping up" + ) + + # Now make the pipeline three times slower than the camera's own period. + monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.06) + slow, _ = _collect(duration_s=2.0) + try: + assert slow, "the loop emitted nothing at all" + ours = _rate(slow) + assert ours < 22.0, f"a 60 ms pipeline still kept up at {ours:.1f} fps" + assert 28.0 <= float(slow[-1].capture_fps) <= 31.0, ( + "the camera's rate was reported as slow too: " + f"{slow[-1].capture_fps}" + ) + assert slow[-1].capture_dropped > 5, ( + "the loop fell behind without recording a single dropped frame: " + f"{slow[-1].capture_dropped}" + ) + finally: + monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0) + + def test_a_file_source_runs_on_the_video_s_own_timeline(): """A pass over a video is stamped with the video's time, not the wall clock.""" from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker