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

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 把这一遍跑一次,只存回放真正要画的东西。 一次约 4.8 分钟。Part A 把这一遍跑一次,只存回放真正要画的东西。
格式版本从 1 升到 2:帧记录里多了深度通道。旧结果不会被误读 —— 版本不符直接 格式版本从 1 升到 2:帧记录里多了深度通道。旧结果不会被误读 —— 版本不符直接
`AnalysisFormatError`,重跑一遍即可(结果本来就是可重算的)。 `AnalysisFormatError`,重跑一遍即可。力的通道**没有**再升版本:它追加在记录最后,老读端按原偏移
仍能读出同一帧,只是没有力(第 1 节末)。
## 1. 结果模型 ## 1. 结果模型
@@ -43,9 +44,27 @@ zlib level 6。
而且一旦有深度值落在岛外就会**静默丢掉一个测量**。 而且一旦有深度值落在岛外就会**静默丢掉一个测量**。
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出 `provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算,`forces` 记录)、`complete` 帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
标志与计数。`counts` 另记 `depth_frames` / `depth_nonzero_frames` / `depth_nonzero_max` / `synthetic` 与 `synthetic_source`、`complete` 标志与计数。`counts` 另记深度与力通道的计数与实测
`depth_pixels` 与实测的 `frame_record_bytes`,进度面板和"这一帧为什么是空的"都读它。 的 `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. Keyframe:候选位置与有效掩码
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
import numpy as np import numpy as np
@@ -30,6 +31,8 @@ from PySide6.QtWidgets import (
from orisys_studio.ui import icons from orisys_studio.ui import icons
from orisys_studio.ui.icons import ICON_SIZE from orisys_studio.ui.icons import ICON_SIZE
logger = logging.getLogger(__name__)
try: try:
import pyqtgraph as pg import pyqtgraph as pg
except ImportError as exc: # pragma: no cover 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 #: True once a source is attached (either kind). ``_store is None`` was
#: the old proxy for this and could not express a disk source. #: the old proxy for this and could not express a disk source.
self._source_ready = False 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. #: The live store to return to when a recording source is detached.
self._live_store = None self._live_store = None
self._mode = "follow" self._mode = "follow"
@@ -473,8 +481,10 @@ class ForcePlotsPanel(QFrame):
for i, plot in enumerate(self.plots): for i, plot in enumerate(self.plots):
try: try:
plot.getViewBox().setBackgroundColor(QColor(bg)) plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception: except Exception: # noqa: BLE001 - a view box that refuses a colour
pass # 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"): for axis_name in ("left", "bottom"):
axis = plot.getAxis(axis_name) axis = plot.getAxis(axis_name)
axis.setPen(axis_pen) axis.setPen(axis_pen)
@@ -842,6 +852,7 @@ class ForcePlotsPanel(QFrame):
self._live_store = store self._live_store = store
self._source = StoreCurveSource(store) self._source = StoreCurveSource(store)
self._source_ready = store is not None self._source_ready = store is not None
self._source_absent_reason = ""
self._on_source_changed() self._on_source_changed()
def set_recording_source(self, reader) -> None: def set_recording_source(self, reader) -> None:
@@ -850,6 +861,29 @@ class ForcePlotsPanel(QFrame):
self._source = RecordingCurveSource(reader) self._source = RecordingCurveSource(reader)
self._source_ready = True 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() self._on_source_changed()
def clear_recording_source(self) -> None: def clear_recording_source(self) -> None:
@@ -857,8 +891,18 @@ class ForcePlotsPanel(QFrame):
if self.is_recording_source(): if self.is_recording_source():
self.set_store(self._live_store) 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: 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: def _switch_unit(self, unit: str) -> None:
"""Move the live buffer to another display unit without misreading it. """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 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 in English and was simply cut mid-word, so the parenthetical lives in the
tooltip and the label stays a name. 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" key = "chart.mode_replay"
else: else:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow" key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
@@ -1397,6 +1455,7 @@ class ForcePlotsPanel(QFrame):
try: try:
ymin, ymax = plot.getViewBox().viewRange()[1] ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction except Exception: # noqa: BLE001 - a view box mid-construction
logger.debug("could not read the chart's view range", exc_info=True)
continue continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax)) zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
@@ -39,6 +39,7 @@ from orisys_app_shared.analysis.model import (
KEYFRAME_HEADER, KEYFRAME_HEADER,
KEYFRAME_MAGIC, KEYFRAME_MAGIC,
KEYFRAME_MASK, KEYFRAME_MASK,
ForceSample,
FramePayload, FramePayload,
KeyframeRef, KeyframeRef,
Provenance, Provenance,
@@ -56,6 +57,7 @@ __all__ = [
"AnalysisNotFoundError", "AnalysisNotFoundError",
"AnalysisReader", "AnalysisReader",
"AnalysisResultError", "AnalysisResultError",
"ForceTable",
] ]
#: How many keyframe payloads to keep decoded. One grid and one mask is the norm; the #: How many keyframe payloads to keep decoded. One grid and one mask is the norm; the
@@ -63,6 +65,36 @@ __all__ = [
_GRID_CACHE = 16 _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): class AnalysisResultError(RuntimeError):
"""Base for every reason a stored result cannot be served.""" """Base for every reason a stored result cannot be served."""
@@ -157,6 +189,8 @@ class AnalysisReader:
self._chunk_index: dict[int, _ChunkIndex] = {} self._chunk_index: dict[int, _ChunkIndex] = {}
self._mask_cache: np.ndarray | None = None self._mask_cache: np.ndarray | None = None
self._mask_loaded = False self._mask_loaded = False
self._forces: ForceTable | None = None
self._forces_loaded = False
self._grid_cache: OrderedDict[int, tuple[np.ndarray, np.ndarray]] = ( self._grid_cache: OrderedDict[int, tuple[np.ndarray, np.ndarray]] = (
OrderedDict() OrderedDict()
) )
@@ -498,8 +532,69 @@ class AnalysisReader:
depth_min=depth_min, depth_min=depth_min,
depth_max=depth_max, depth_max=depth_max,
depth_stored_bytes=depth_stored, 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: 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. """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: class RecordingCurveSource:
"""A recording on disk: interval reads plus a cached envelope.""" """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" DERIVED_DIR_NAME = "OrisysStudio"
#: Subdirectory holding the offline analysis results. #: Subdirectory holding the offline analysis results.
ANALYSIS_CACHE_DIRNAME = "analysis-cache" 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: def derived_data_dir() -> Path:
@@ -186,6 +188,18 @@ def analysis_cache_dir() -> Path:
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME 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: def preference_path() -> Path:
return app_root() / "preference.json" 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 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. 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 What the picker *says* about an entry is deliberately not what it is called internally. A row is
label names the product, whether the bundle shipped with the app or came from the operator, and the bundle's name — the descriptor's ``display_name``, else the folder name — and nothing else.
what the calibration selection gives — a named file, or raw force. The ``profile_id``, the The origin word, the calibration phrasing and the SDK's own ``ok``/``degraded``/``failed``
bundle's shape and the SDK's own ``ok``/``degraded``/``failed`` verdict live in verdict belong to the details surface under the selector, which is where the operator reads them
:meth:`CatalogEntry.tooltip`: they inform the operator who asked, instead of heading the row (:meth:`CatalogEntry.tooltip` is the same facts on hover). A row is qualified only where two
with a word that only says "something optional is missing". entries offered together would otherwise read alike; see :func:`row_labels`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,6 +39,7 @@ import contextlib
import json import json
import os import os
import uuid import uuid
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -219,8 +220,15 @@ class CatalogEntry:
@property @property
def name(self) -> str: def name(self) -> str:
"""What the operator calls this configuration: its name, family, or folder.""" """The bundle's name: the descriptor's ``display_name``, else its folder name.
return self.display_name or self.product_family or self.folder
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 @property
def origin_label(self) -> str: def origin_label(self) -> str:
@@ -259,17 +267,14 @@ class CatalogEntry:
return path.name if path is not None else "" return path.name if path is not None else ""
@property @property
def calibration_note(self) -> str: def calibration_selection(self) -> str:
"""What the calibration *selection* gives: a named file, or raw force. """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 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 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. uncalibrated, and never as a defect.
""" """
selected = self.selected_calibration return self.selected_calibration or "No calibration selected — raw force"
if selected:
return f"Calibration: {selected}"
return "No calibration selected — raw force"
@property @property
def duplicate(self) -> bool: def duplicate(self) -> bool:
@@ -292,30 +297,72 @@ class CatalogEntry:
) )
return None return None
def label(self) -> str: def error_sentences(self) -> tuple[str, ...]:
"""One line for a picker item: what it is, where it came from, what it will give. """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 The reason is built out of the errors, so repeating them beside it would bury the one
``ok``/``degraded``/``failed`` verdict (a severity with no information in it) and the line that explains the entry. What is left is still said, never dropped.
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.
""" """
name = self.name return tuple(
if not self.valid: _sentence(issue) for issue in self.errors if str(issue) not in self.reason
return f"{name} · Cannot start: {self.start_blocked_reason}" )
origin = self.origin_label
if self.duplicates: def warning_sentences(self) -> tuple[str, ...]:
return f"{name} · {origin} · Also provided by {', '.join(self.duplicates)}" """Every warning as a readable sentence rather than as an issue code."""
return f"{name} · {origin} · {self.calibration_note}" 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: 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 technical half the row does not carry: the identity the SDK assigned, how the bundle
the bundle was read, its validation, and every finding as a sentence rather than as a was read, its validation, and every finding as a sentence rather than as a code —
code — ``calibration.absent`` says nothing an operator can use, "calibrated force is ``calibration.absent`` says nothing an operator can use, "calibrated force is unavailable;
unavailable; raw force is available" says everything. raw force is available" says everything.
""" """
state_note = _STATE_NOTES.get(self.state, "verdict not recognised") state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
lines = [ lines = [
@@ -325,19 +372,13 @@ class CatalogEntry:
f"processing backend: {self.processing_backend or 'unknown'}", f"processing backend: {self.processing_backend or 'unknown'}",
f"origin: {self.origin} ({self.bundle_path})", f"origin: {self.origin} ({self.bundle_path})",
f"SDK validation: {self.state} ({state_note})", 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 blocked = self.start_blocked_reason
if blocked: if blocked:
lines.append(f"cannot start: {blocked}") lines.append(f"cannot start: {blocked}")
# The reason already quotes the errors it was built from; repeating them here would lines.extend(f"error: {text}" for text in self.error_sentences())
# bury the one line that explains the entry. lines.extend(f"warning: {text}" for text in self.warning_sentences())
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)
return "\n".join(lines) return "\n".join(lines)
@@ -427,6 +468,17 @@ class SensorCatalog:
) )
return None if entry is None else entry.start_blocked_reason 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]: def summary_lines(self) -> list[str]:
"""A short block for a log header or a diagnostics panel.""" """A short block for a log header or a diagnostics panel."""
lines = [ lines = [
@@ -438,11 +490,63 @@ class SensorCatalog:
for entry in self.entries: for entry in self.entries:
blocked = entry.start_blocked_reason blocked = entry.start_blocked_reason
lines.append( 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 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: def resolve_user_root() -> UserRootDecision:
"""The writable user bundle root, preferring the portable sibling. """The writable user bundle root, preferring the portable sibling.