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

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
@@ -39,6 +39,7 @@ from orisys_app_shared.analysis.model import (
KEYFRAME_HEADER,
KEYFRAME_MAGIC,
KEYFRAME_MASK,
ForceSample,
FramePayload,
KeyframeRef,
Provenance,
@@ -56,6 +57,7 @@ __all__ = [
"AnalysisNotFoundError",
"AnalysisReader",
"AnalysisResultError",
"ForceTable",
]
#: How many keyframe payloads to keep decoded. One grid and one mask is the norm; the
@@ -63,6 +65,36 @@ __all__ = [
_GRID_CACHE = 16
@dataclass(frozen=True)
class ForceTable:
"""A whole result's force trace, in the shape a curve source reads.
``times`` is the recording's own signal time, one row per stored frame; ``raw`` is the
device triplet and ``newtons`` the calibrated one, both with ``NaN`` where the chain
had no value — a gap in the trace, never a zero in its place. ``calibrated`` says
whether *any* frame came from a calibration curve, so a chart can offer a unit only
when there is something to show in it.
"""
times: np.ndarray
raw: np.ndarray
newtons: np.ndarray
valid: np.ndarray
frame_ids: np.ndarray
calibrated: bool = False
def __len__(self) -> int:
return int(self.times.size)
@property
def axis_available(self) -> np.ndarray:
"""Per axis: whether any row carries a finite value."""
return np.isfinite(self.raw).any(axis=0) | np.isfinite(self.newtons).any(axis=0)
def value_for_unit(self, unit: str) -> np.ndarray:
return self.newtons if str(unit).upper().startswith("N") else self.raw
class AnalysisResultError(RuntimeError):
"""Base for every reason a stored result cannot be served."""
@@ -157,6 +189,8 @@ class AnalysisReader:
self._chunk_index: dict[int, _ChunkIndex] = {}
self._mask_cache: np.ndarray | None = None
self._mask_loaded = False
self._forces: ForceTable | None = None
self._forces_loaded = False
self._grid_cache: OrderedDict[int, tuple[np.ndarray, np.ndarray]] = (
OrderedDict()
)
@@ -498,8 +532,69 @@ class AnalysisReader:
depth_min=depth_min,
depth_max=depth_max,
depth_stored_bytes=depth_stored,
force=decoded.force,
)
def force(self, frame_id: int) -> ForceSample | None:
"""One frame's stored force sample, or ``None`` when the frame carries none."""
return self.frame(int(frame_id)).force
@property
def has_force(self) -> bool:
"""Whether *any* frame in this result carries a force sample.
The provenance is the cheap answer, but it is the pass's intent rather than the
file's content; a result whose force the writer never received would otherwise be
offered a chart from a promise. The first frame is enough to answer it: a pass
either collected force for every frame it wrote or for none of them.
"""
if "forces" not in self.provenance.sources:
return False
ids = self.frame_ids
if ids.size == 0:
return False
return self.force(int(ids[0])) is not None
def forces(self) -> ForceTable | None:
"""Every stored force sample, as one table, or ``None`` when there is none.
Read once and cached: the charts ask for a window, an envelope and a set of
statistics of the same trace, and scanning the chunk files three times to answer
the same question is the difference between a redraw and a pause. The cost is six
float32 values per frame — 0.4 MB for a ten-minute recording at 30 fps.
"""
if self._forces_loaded:
return self._forces
self._forces_loaded = True
ids = self.frame_ids
if ids.size == 0 or not self.has_force:
self._forces = None
return None
times = np.empty(ids.size, dtype=np.float64)
raw = np.full((ids.size, 3), np.nan, dtype=np.float64)
newtons = np.full((ids.size, 3), np.nan, dtype=np.float64)
valid = np.zeros(ids.size, dtype=bool)
calibrated = False
for slot, frame_id in enumerate(ids):
payload = self.frame(int(frame_id))
sample = payload.force
times[slot] = float(payload.t_rel_s)
if sample is None:
continue
raw[slot] = sample.raw()
newtons[slot] = sample.newtons()
valid[slot] = bool(sample.valid)
calibrated = calibrated or bool(sample.calibrated)
self._forces = ForceTable(
times=times,
raw=raw,
newtons=newtons,
valid=valid,
frame_ids=ids.astype(np.int64),
calibrated=calibrated,
)
return self._forces
def frame_at(self, t_rel_s: float) -> FramePayload | None:
"""The stored frame at or just before ``t_rel_s``, or ``None`` for an empty result.