From 92b2f2fcd7a79a4a18cd96b6c86095af5e6cae38 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 5 Oct 2026 16:57:02 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=B8=E8=A7=86=E9=A2=91=E5=8F=AF=E5=88=86?= =?UTF-8?q?=E6=9E=90=EF=BC=9A=E7=94=9F=E6=88=90=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E3=80=81=E8=AE=BE=E7=BD=AE=E6=8F=90=E7=A4=BA=E3=80=81=E9=80=90?= =?UTF-8?q?=E5=B8=A7=E5=8A=9B=E4=B8=8E=E6=8C=89=E6=9D=A5=E6=BA=90=E9=80=89?= =?UTF-8?q?=E7=94=BB=E7=AC=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- apps/studio/docs/replay-analysis.md | 27 ++- .../src/orisys_studio/ui/force_plots.py | 67 +++++- .../src/orisys_app_shared/analysis/reader.py | 95 +++++++++ shared/src/orisys_app_shared/force/source.py | 176 ++++++++++++++++ shared/src/orisys_app_shared/paths.py | 14 ++ .../sensors/bundle_catalog.py | 190 ++++++++++++++---- 6 files changed, 518 insertions(+), 51 deletions(-) diff --git a/apps/studio/docs/replay-analysis.md b/apps/studio/docs/replay-analysis.md index a25b03e..24e1b7b 100644 --- a/apps/studio/docs/replay-analysis.md +++ b/apps/studio/docs/replay-analysis.md @@ -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:候选位置与有效掩码 diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index cf37631..f278d67 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -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)) diff --git a/shared/src/orisys_app_shared/analysis/reader.py b/shared/src/orisys_app_shared/analysis/reader.py index e0b3eb0..a11ea3e 100644 --- a/shared/src/orisys_app_shared/analysis/reader.py +++ b/shared/src/orisys_app_shared/analysis/reader.py @@ -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. diff --git a/shared/src/orisys_app_shared/force/source.py b/shared/src/orisys_app_shared/force/source.py index 1d7d94c..598b93a 100644 --- a/shared/src/orisys_app_shared/force/source.py +++ b/shared/src/orisys_app_shared/force/source.py @@ -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.""" diff --git a/shared/src/orisys_app_shared/paths.py b/shared/src/orisys_app_shared/paths.py index 7c05bb6..3872b09 100644 --- a/shared/src/orisys_app_shared/paths.py +++ b/shared/src/orisys_app_shared/paths.py @@ -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" diff --git a/shared/src/orisys_app_shared/sensors/bundle_catalog.py b/shared/src/orisys_app_shared/sensors/bundle_catalog.py index 4ba0430..d9cd3e5 100644 --- a/shared/src/orisys_app_shared/sensors/bundle_catalog.py +++ b/shared/src/orisys_app_shared/sensors/bundle_catalog.py @@ -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.