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

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.
@@ -164,6 +164,182 @@ class StoreCurveSource:
)
class AnalysisCurveSource:
"""A stored analysis result's **recomputed** force trace, as a curve source.
A bare video has no ``forces.csv``: the only force there is the one the vision chain
computes from the frames, which the pass stores per frame (see
``analysis.model.FRAME_FLAG_FORCE``). This reads that trace back through the same
interface the recording's CSV is read through, so the charts need no second drawing
path — and its ``kind`` is deliberately neither ``disk`` nor ``memory``: a readout
drawn from a recomputation and one drawn from a measurement must be tellable apart by
the surface that shows them, which is the whole reason both exist.
``valid`` rows are the chain's own verdict intersected with finiteness: a ``NaN`` is a
frame the chain had no number for, and a gap in the trace is the honest picture of it.
"""
kind = "analysis"
def __init__(self, reader, *, unit: str = "raw") -> None:
self.reader = reader
self.unit = "N" if str(unit).upper().startswith("N") else "raw"
self._table = None
self._loaded = False
# ------------------------------------------------------------------- loading
def table(self):
"""The stored trace, read once from the result."""
if not self._loaded:
self._loaded = True
try:
self._table = self.reader.forces()
except Exception: # noqa: BLE001 - an unreadable trace is an empty one, said so
self._table = None
if self._table is not None and not bool(self._table.calibrated):
# No curve was applied anywhere in this result, so there is nothing to
# offer in newtons: keep the raw counts rather than trading them for NaN.
self.unit = "raw"
return self._table
def unit_available(self, unit: str) -> bool:
"""Whether this trace can be drawn in ``unit`` at all."""
table = self.table()
if table is None:
return False
if not str(unit).upper().startswith("N"):
return bool(np.isfinite(table.raw).any())
return bool(table.calibrated and np.isfinite(table.newtons).any())
# ------------------------------------------------------------------- protocol
def extent(self) -> tuple[float, float]:
table = self.table()
if table is None or len(table) == 0:
return (0.0, 0.0)
return (float(table.times[0]), float(table.times[-1]))
def duration_s(self) -> float:
first, last = self.extent()
return max(0.0, last - first)
def window(
self,
t0: float,
t1: float,
*,
max_points: int | None = None,
unit: str | None = None,
) -> SourceWindow:
table = self.table()
wanted = self._normalise(unit)
if table is None or len(table) == 0:
return self._empty(wanted)
keep = (table.times >= float(t0)) & (table.times <= float(t1))
values = table.value_for_unit(wanted)[keep]
times = table.times[keep]
valid = table.valid[keep]
frame_ids = table.frame_ids[keep]
decimated = False
if max_points and times.size > int(max_points):
rows = _extreme_rows(values, int(max_points))
times, values, valid = times[rows], values[rows], valid[rows]
frame_ids = frame_ids[rows]
decimated = True
return SourceWindow(
times=times,
values=values,
valid=valid,
frame_ids=frame_ids,
unit=wanted,
decimated=decimated,
)
def envelope(self, *, buckets: int = 1200, unit: str | None = None) -> SourceWindow:
"""The whole trace as one row per bucket, the way a recording's is drawn.
Each bucket contributes its own extreme row per plotted axis, so a shear spike
survives the decimation instead of being averaged into the middle of the band.
"""
table = self.table()
wanted = self._normalise(unit)
if table is None or len(table) == 0:
return self._empty(wanted)
values = table.value_for_unit(wanted)
total = len(table)
count = max(1, min(int(buckets), total))
edges = np.linspace(0, total, num=count + 1).astype(np.int64)
rows: list[int] = []
for index in range(count):
start = int(edges[index])
stop = max(int(edges[index + 1]), start + 1)
chunk = values[start:stop]
if not chunk.size:
continue
finite = np.isfinite(chunk)
if not finite.any():
rows.append(start)
continue
ordered = np.where(finite, chunk, -np.inf)
extremes = {int(np.argmax(ordered[:, axis])) for axis in range(chunk.shape[1])}
extremes |= {
int(np.argmin(ordered[:, axis]))
for axis in range(chunk.shape[1])
if finite[:, axis].any()
}
rows.extend(start + offset for offset in sorted(extremes))
picked = np.asarray(sorted(set(rows)), dtype=np.int64)
if picked.size == 0:
return self._empty(wanted)
return SourceWindow(
times=table.times[picked],
values=values[picked],
valid=table.valid[picked],
frame_ids=table.frame_ids[picked],
unit=wanted,
decimated=True,
)
def statistics(self, t0: float, t1: float, *, unit: str | None = None) -> SourceStats:
return self.window(t0, t1, unit=self._normalise(unit)).statistics()
# ------------------------------------------------------------------- helpers
def _normalise(self, unit: str | None) -> str:
wanted = self.unit if unit is None else unit
wanted = "N" if str(wanted).upper().startswith("N") else "raw"
return wanted
def _empty(self, unit: str) -> SourceWindow:
empty = np.empty(0, dtype=np.float64)
return SourceWindow(
times=empty,
values=np.empty((0, 3), dtype=np.float64),
valid=np.empty(0, dtype=bool),
frame_ids=np.empty(0, dtype=np.int64),
unit=unit,
)
def _extreme_rows(values: np.ndarray, max_points: int) -> np.ndarray:
"""Row indices keeping every axis' extreme per bucket, so a spike survives a window."""
total = int(values.shape[0])
budget = max(4, int(max_points))
bucket = max(1, total // max(1, budget // 6))
picked: set[int] = set()
for start in range(0, total, bucket):
stop = min(total, start + bucket)
for axis in range(values.shape[1]):
column = values[start:stop, axis]
finite = np.isfinite(column)
if not finite.any():
continue
ordered = np.where(finite, column, -np.inf)
picked.add(start + int(np.argmin(ordered)))
picked.add(start + int(np.argmax(ordered)))
if not picked:
return np.arange(total)
return np.asarray(sorted(picked), dtype=np.int64)
class RecordingCurveSource:
"""A recording on disk: interval reads plus a cached envelope."""
+14
View File
@@ -153,6 +153,8 @@ def recordings_dir() -> Path:
DERIVED_DIR_NAME = "OrisysStudio"
#: Subdirectory holding the offline analysis results.
ANALYSIS_CACHE_DIRNAME = "analysis-cache"
#: Subdirectory holding the workspaces generated for videos that are not recordings.
VIDEO_WORKSPACE_DIRNAME = "video-workspaces"
def derived_data_dir() -> Path:
@@ -186,6 +188,18 @@ def analysis_cache_dir() -> Path:
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME
def video_workspace_dir() -> Path:
"""Where a bare video's generated workspace lives: the app's own root.
A video with no recording beside it has no ``frames.csv`` and no ``session.json``, and
the analysis pass reads nothing else. Rather than teach the pass a second input shape,
the two files it already expects are *manufactured* for the video — and they are the
app's own derived data, so they belong here and never in the folder the operator keeps
the footage in. See :mod:`orisys_app_shared.analysis.bare_video`.
"""
return derived_data_dir() / VIDEO_WORKSPACE_DIRNAME
def preference_path() -> Path:
return app_root() / "preference.json"
@@ -25,12 +25,12 @@ hiding it is how the same file ends up offered as a sensor config.
Two bundles that declare one ``profile_id`` are both listed and each names the other. Nothing
is shadowed, and the ambiguity is available to a caller that must refuse to start.
What the picker *says* about an entry is deliberately not what it is called internally. The
label names the product, whether the bundle shipped with the app or came from the operator, and
what the calibration selection gives — a named file, or raw force. The ``profile_id``, the
bundle's shape and the SDK's own ``ok``/``degraded``/``failed`` verdict live in
:meth:`CatalogEntry.tooltip`: they inform the operator who asked, instead of heading the row
with a word that only says "something optional is missing".
What the picker *says* about an entry is deliberately not what it is called internally. A row is
the bundle's name — the descriptor's ``display_name``, else the folder name — and nothing else.
The origin word, the calibration phrasing and the SDK's own ``ok``/``degraded``/``failed``
verdict belong to the details surface under the selector, which is where the operator reads them
(:meth:`CatalogEntry.tooltip` is the same facts on hover). A row is qualified only where two
entries offered together would otherwise read alike; see :func:`row_labels`.
"""
from __future__ import annotations
@@ -39,6 +39,7 @@ import contextlib
import json
import os
import uuid
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -219,8 +220,15 @@ class CatalogEntry:
@property
def name(self) -> str:
"""What the operator calls this configuration: its name, family, or folder."""
return self.display_name or self.product_family or self.folder
"""The bundle's name: the descriptor's ``display_name``, else its folder name.
The descriptor's name is the identity an operator can act on — it is what the bundle
calls itself, and the SDK documents it as the picker's text. Without one, the folder
name is the name: it is what the operator called the bundle when they put it there.
Where two visible entries still read alike, :meth:`SensorCatalog.row_label` adds the
suffix that tells them apart.
"""
return self.display_name or self.folder
@property
def origin_label(self) -> str:
@@ -259,17 +267,14 @@ class CatalogEntry:
return path.name if path is not None else ""
@property
def calibration_note(self) -> str:
"""What the calibration *selection* gives: a named file, or raw force.
def calibration_selection(self) -> str:
"""The calibration selection as what it is: a named file, or raw force.
A calibration is a separate optional artifact chosen for a session, never a property of
the bundle, so an absent one is stated as what is not selected — not as a bundle that is
uncalibrated, and never as a defect.
"""
selected = self.selected_calibration
if selected:
return f"Calibration: {selected}"
return "No calibration selected — raw force"
return self.selected_calibration or "No calibration selected — raw force"
@property
def duplicate(self) -> bool:
@@ -292,30 +297,72 @@ class CatalogEntry:
)
return None
def label(self) -> str:
"""One line for a picker item: what it is, where it came from, what it will give.
def error_sentences(self) -> tuple[str, ...]:
"""Every error as a readable sentence, minus the ones the reason already quotes.
Deliberately absent: the ``profile_id`` (identity no operator can act on), the SDK's
``ok``/``degraded``/``failed`` verdict (a severity with no information in it) and the
word ``legacy`` (which means "read through the compatibility adapter", not "old").
What is left is the choice itself: the product, its origin, and the force a session
started from it will produce.
The reason is built out of the errors, so repeating them beside it would bury the one
line that explains the entry. What is left is still said, never dropped.
"""
name = self.name
if not self.valid:
return f"{name} · Cannot start: {self.start_blocked_reason}"
origin = self.origin_label
if self.duplicates:
return f"{name} · {origin} · Also provided by {', '.join(self.duplicates)}"
return f"{name} · {origin} · {self.calibration_note}"
return tuple(
_sentence(issue) for issue in self.errors if str(issue) not in self.reason
)
def warning_sentences(self) -> tuple[str, ...]:
"""Every warning as a readable sentence rather than as an issue code."""
return tuple(_sentence(issue) for issue in self.warnings)
def capability_line(self) -> str:
"""What this profile can do, in one line — flow / force / axes / depth / 3D.
The line the setup prompt offers next to the bundle's name, so choosing one is a
decision about *processing* rather than about a folder. It states only what is
available: a capability that is not there is not a fact about the processing, and
whose *reason* it is belongs in the tooltip beside it (``tooltip`` already carries
every finding as a sentence). ``deformation`` is named as what the profile says it
measures — optical flow in pixels, relative displacement or relative depth — since
"depth" is one of three possible meanings and a bare "depth: yes" would be wrong
for the other two.
"""
profile = self.profile
if profile is None:
return ""
capabilities = profile.capabilities
parts: list[str] = []
if capabilities.video_processing.available:
parts.append("video")
if capabilities.optical_flow.available:
parts.append("flow")
if capabilities.raw_force.available:
parts.append("raw force")
axes = [
name
for name, availability in (
("fn", capabilities.calibrated_force.fn),
("fx", capabilities.calibrated_force.fx),
("fy", capabilities.calibrated_force.fy),
)
if availability.available
]
if axes:
parts.append(f"calibrated axes ({', '.join(axes)})")
if capabilities.deformation.available:
# The SDK's own detail for this capability is a sentence ("relative depth from
# the deformation field"), and the capability's documented meanings are
# optical flow in pixels, relative displacement and relative depth. "Depth" is
# the one that is always true of a deformation field here, and the sentence it
# came from stays in the tooltip rather than being truncated into the line.
parts.append("depth")
if capabilities.visualization_3d.available:
parts.append("3D")
return " · ".join(parts)
def tooltip(self) -> str:
"""The facts behind the label, including why the entry is not startable.
"""The facts behind the row, including why the entry is not startable.
The technical half the picker row has no room for: the identity the SDK assigned, how
the bundle was read, its validation, and every finding as a sentence rather than as a
code — ``calibration.absent`` says nothing an operator can use, "calibrated force is
unavailable; raw force is available" says everything.
The technical half the row does not carry: the identity the SDK assigned, how the bundle
was read, its validation, and every finding as a sentence rather than as a code —
``calibration.absent`` says nothing an operator can use, "calibrated force is unavailable;
raw force is available" says everything.
"""
state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
lines = [
@@ -325,19 +372,13 @@ class CatalogEntry:
f"processing backend: {self.processing_backend or 'unknown'}",
f"origin: {self.origin} ({self.bundle_path})",
f"SDK validation: {self.state} ({state_note})",
f"calibration: {self.selected_calibration or 'none selected — raw force'}",
f"calibration selection: {self.calibration_selection}",
]
blocked = self.start_blocked_reason
if blocked:
lines.append(f"cannot start: {blocked}")
# The reason already quotes the errors it was built from; repeating them here would
# bury the one line that explains the entry.
lines.extend(
f"error: {_sentence(issue)}"
for issue in self.errors
if str(issue) not in self.reason
)
lines.extend(f"warning: {_sentence(issue)}" for issue in self.warnings)
lines.extend(f"error: {text}" for text in self.error_sentences())
lines.extend(f"warning: {text}" for text in self.warning_sentences())
return "\n".join(lines)
@@ -427,6 +468,17 @@ class SensorCatalog:
)
return None if entry is None else entry.start_blocked_reason
def row_label(self, entry: CatalogEntry) -> str:
"""The one line this entry gets in a picker row, told apart from its neighbours.
A row is the bundle's name and nothing else. The exception is a name two entries share
while being offered side by side — the same folder under both roots, or one
``display_name`` declared twice — and those carry the suffix :func:`row_labels` picks.
Computed from the family group, which is what the picker shows at one time: two entries
in groups that are never visible together keep the plain name.
"""
return row_labels(self.entries_for_family(entry.family)).get(entry.key, entry.name)
def summary_lines(self) -> list[str]:
"""A short block for a log header or a diagnostics panel."""
lines = [
@@ -438,11 +490,63 @@ class SensorCatalog:
for entry in self.entries:
blocked = entry.start_blocked_reason
lines.append(
f" {entry.ref}: {entry.label()}" + (f" - {blocked}" if blocked else "")
f" {entry.ref}: {self.row_label(entry)}"
+ (f" - {blocked}" if blocked else "")
)
return lines
def row_labels(entries: Iterable[CatalogEntry]) -> dict[str, str]:
"""Row text per entry key for one visible group: the name, plus a suffix only on a collision.
Two entries may legitimately share a name — ``shipped/box`` beside ``sensors-user/box``, or
two folders whose descriptors declare one ``display_name``. Neither is dropped or shadowed,
so each has to be tellable apart in the list they share, and the suffix says which one the
operator is looking at. The origin is the suffix the picker is meant to show, because that is
what separates the bundle the app shipped from the operator's own copy of it; when one root
supplies both sides of a collision, the origin cannot separate them and the folder — the
identity that actually differs there — is used instead. Should even that read alike (a folder
named like an origin), the full ``origin/folder`` ref is used, so no row is ever identical to
another. Deterministic: the same tree always produces the same rows.
"""
labels: dict[str, str] = {}
for name, group in _group_by_name(entries).items():
if len(group) == 1:
labels[group[0].key] = name
continue
labels.update(_collision_labels(name, group))
return labels
def _group_by_name(entries: Iterable[CatalogEntry]) -> dict[str, list[CatalogEntry]]:
"""The entries by the name they would otherwise read as, in the order they were given."""
groups: dict[str, list[CatalogEntry]] = {}
for entry in entries:
groups.setdefault(entry.name, []).append(entry)
return groups
def _collision_labels(
name: str, group: Sequence[CatalogEntry]
) -> dict[str, str]:
"""One name shared by several visible entries: each row with the suffix that separates it."""
by_origin: dict[str, int] = {}
for entry in group:
origin = entry.origin_label.lower()
by_origin[origin] = by_origin.get(origin, 0) + 1
labels: dict[str, str] = {}
used: set[str] = set()
for entry in group:
origin = entry.origin_label.lower()
preferred = origin if by_origin[origin] == 1 else entry.folder
for suffix in (preferred, entry.ref):
if suffix not in used:
break
used.add(suffix)
labels[entry.key] = f"{name} ({suffix})"
return labels
def resolve_user_root() -> UserRootDecision:
"""The writable user bundle root, preferring the portable sibling.