裸视频可分析:生成工作区、设置提示、逐帧力与按来源选画笔
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user