裸视频可分析:生成工作区、设置提示、逐帧力与按来源选画笔

pass 只认 frames.csv 与 session.json,裸视频此前因此只能播放;现在为它在应用自己的目录里生成
这两份文件并复用既有链路,链路重算的力按帧存入结果并标注为「重算」,box 的空白箭头画布改按帧
来源走共享画笔。
apps/studio/tests 497 项、shared/tests 471 项(2 个已知墙钟失败)、autocal 358 项、ui_check 634 项
均 0 失败,lint_gate 与 doc_lint 无发现;真实 GL 快照见 .scratch/bare-video/snapshots。
This commit is contained in:
ykcheungab
2026-10-05 16:57:02 +08:00
parent c0a10e6adb
commit 92b2f2fcd7
6 changed files with 518 additions and 51 deletions
+23 -4
View File
@@ -9,7 +9,8 @@
一次约 4.8 分钟。Part A 把这一遍跑一次,只存回放真正要画的东西。
格式版本从 1 升到 2:帧记录里多了深度通道。旧结果不会被误读 —— 版本不符直接
`AnalysisFormatError`,重跑一遍即可(结果本来就是可重算的)。
`AnalysisFormatError`,重跑一遍即可。力的通道**没有**再升版本:它追加在记录最后,老读端按原偏移
仍能读出同一帧,只是没有力(第 1 节末)。
## 1. 结果模型
@@ -43,9 +44,27 @@ zlib level 6。
而且一旦有深度值落在岛外就会**静默丢掉一个测量**。
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算,`forces` 记录)、`complete`
标志与计数。`counts` 另记 `depth_frames` / `depth_nonzero_frames` / `depth_nonzero_max` /
`depth_pixels` 与实测的 `frame_record_bytes`,进度面板和"这一帧为什么是空的"都读它。
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
`synthetic` 与 `synthetic_source`、`complete` 标志与计数。`counts` 另记深度与力通道的计数与实测
的 `frame_record_bytes`,进度面板和"这一帧为什么是空的"都读它。
**帧记录可以再带一份力,追加在最后。** 裸视频没有 `forces.csv`,pass 就把链路算出的力按帧存下:
`FRAME_FLAG_FORCE` 后接六个 float32 与两个标志位,28 B/帧。版本不变的理由见开头;读数取不到时存
`NaN`。
### 1.1 裸视频的输入:生成的工作区
pass 只认一种输入形状(一个目录里的 `frames.csv`、`session.json` 与 `raw_*.mp4`),而形状本身是
好的:帧身份、时间与链路都来自录制自己的元数据。裸视频因此**不新增第二条代码路径**,而是把这两个
文件为它*生成*出来(`analysis/bare_video.py`):`frames.csv` 给容器每一帧一行
(`t_rel_s = index / fps`),`session.json` 给出 `device`/`stats`/`files` 与一个 `synthetic` 块;
裸视频只有容器时间,所以容器的钟**就是**信号时间(第 5 节)。
凡是 `recording_identity` 会哈希的字段都只由**视频与所选配置**决定,不含任何时钟:重开同一条视频
就是同一个 key;标定进 key 的是**文件内容**的摘要。视频以 `raw_0001.mp4` 放进工作区(能硬链接就
链接):一个文件就是一段,因此**多文件输入直接拒绝**并列出文件名。工作区写在应用自己的派生数据根
(`paths.video_workspace_dir()`),**绝不写在操作员视频旁边**;`synthetic` 块被 pass 抄进
`provenance.synthetic`/`synthetic_source`,重算出来的测量因此不可能被当成记录的测量读。
## 2. Keyframe:候选位置与有效掩码
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import time
import numpy as np
@@ -30,6 +31,8 @@ from PySide6.QtWidgets import (
from orisys_studio.ui import icons
from orisys_studio.ui.icons import ICON_SIZE
logger = logging.getLogger(__name__)
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
@@ -190,6 +193,11 @@ class ForcePlotsPanel(QFrame):
#: True once a source is attached (either kind). ``_store is None`` was
#: the old proxy for this and could not express a disk source.
self._source_ready = False
#: Non-empty when there is no trace to draw *and that is a fact about the input*:
#: a bare video whose pass has not run, or a profile whose chain produced no force
#: for it. The mode line states it rather than leaving an empty plot under wording
#: that implies data is on its way.
self._source_absent_reason = ""
#: The live store to return to when a recording source is detached.
self._live_store = None
self._mode = "follow"
@@ -473,8 +481,10 @@ class ForcePlotsPanel(QFrame):
for i, plot in enumerate(self.plots):
try:
plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception:
pass
except Exception: # noqa: BLE001 - a view box that refuses a colour
# Reported rather than swallowed: a plot left on the previous theme's
# background is a visible defect, and only this line says why it happened.
logger.debug("could not set the chart background", exc_info=True)
for axis_name in ("left", "bottom"):
axis = plot.getAxis(axis_name)
axis.setPen(axis_pen)
@@ -842,6 +852,7 @@ class ForcePlotsPanel(QFrame):
self._live_store = store
self._source = StoreCurveSource(store)
self._source_ready = store is not None
self._source_absent_reason = ""
self._on_source_changed()
def set_recording_source(self, reader) -> None:
@@ -850,6 +861,29 @@ class ForcePlotsPanel(QFrame):
self._source = RecordingCurveSource(reader)
self._source_ready = True
self._source_absent_reason = ""
self._on_source_changed()
def set_curve_source(self, source, *, reason: str = "") -> None:
"""Attach any curve source, or say why there is no trace to draw.
The charts used to have exactly two states: the live session store, and one
recording's ``forces.csv``. A bare video has neither — the force there is the
analysis pass's own recomputation, stored inside the result — and the panel has to
be able to read it *and* say which of the two it is reading, because a recomputed
trace and a recorded one are different claims about the same footage.
``source is None`` with a ``reason`` is a state, not an empty chart: the mode line
states that reason instead of leaving "following live" over a flat plot.
"""
if source is None:
self._source = StoreCurveSource(self._live_store)
self._source_ready = False
self._source_absent_reason = str(reason or "")
else:
self._source = source
self._source_ready = True
self._source_absent_reason = ""
self._on_source_changed()
def clear_recording_source(self) -> None:
@@ -857,8 +891,18 @@ class ForcePlotsPanel(QFrame):
if self.is_recording_source():
self.set_store(self._live_store)
def curve_kind(self) -> str:
"""Which kind of source is attached: ``memory``, ``disk`` or ``analysis``."""
return str(getattr(self._source, "kind", ""))
def is_recording_source(self) -> bool:
return str(getattr(self._source, "kind", "")) == "disk"
"""True when the curves are read from a file rather than the live session.
Both a recording's own CSV and a result's recomputed trace count: neither is the
live store, and every caller that asks this question (the "back to latest" button,
the redraw paths) means "not the live data" rather than "specifically disk".
"""
return self.curve_kind() in ("disk", "analysis")
def _switch_unit(self, unit: str) -> None:
"""Move the live buffer to another display unit without misreading it.
@@ -1274,8 +1318,22 @@ class ForcePlotsPanel(QFrame):
continue)") did not fit next to the header's icon buttons at 1280–1440 px
in English and was simply cut mid-word, so the parenthetical lives in the
tooltip and the label stays a name.
Three sources can be attached, and two of them are *files* — a recording's own
``forces.csv`` and an analysis result's recomputed trace. They are different
claims about the same footage, so they get different names rather than one
"replay" that would hide which number came from where.
"""
if self.is_recording_source():
if self._source_absent_reason:
key = "chart.mode_no_trace"
self.mode_label.setText(t(key, reason=self._source_absent_reason))
self.mode_label.setToolTip(t("chart.mode_no_trace_tip"))
self.btn_latest.setEnabled(False)
self.btn_overview.setEnabled(False)
return
if self.curve_kind() == "analysis":
key = "chart.mode_recomputed"
elif self.is_recording_source():
key = "chart.mode_replay"
else:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
@@ -1397,6 +1455,7 @@ class ForcePlotsPanel(QFrame):
try:
ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction
logger.debug("could not read the chart's view range", exc_info=True)
continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))