把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口
控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。 验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""Sensor catalog helpers.
|
||||
|
||||
The catalog answers three questions the picker and the resolver both need: which configuration
|
||||
bundles exist, what each one is (family, profile, origin and the SDK's verdict), and where a
|
||||
user's own bundles go on this machine.
|
||||
bundles exist, what each one is (name, origin, calibration selection and the SDK's verdict), and
|
||||
where a user's own bundles go on this machine.
|
||||
"""
|
||||
|
||||
from orisys_app_shared.sensors.bundle_catalog import (
|
||||
@@ -14,6 +14,9 @@ from orisys_app_shared.sensors.bundle_catalog import (
|
||||
ROOT_KIND_EXPLICIT,
|
||||
ROOT_KIND_FALLBACK,
|
||||
ROOT_KIND_SIBLING,
|
||||
SHAPE_COMPATIBILITY,
|
||||
SHAPE_DESCRIPTOR,
|
||||
SHAPE_UNREADABLE,
|
||||
STATE_UNREADABLE,
|
||||
CatalogEntry,
|
||||
CatalogRoot,
|
||||
@@ -33,6 +36,9 @@ __all__ = [
|
||||
"ROOT_KIND_EXPLICIT",
|
||||
"ROOT_KIND_FALLBACK",
|
||||
"ROOT_KIND_SIBLING",
|
||||
"SHAPE_COMPATIBILITY",
|
||||
"SHAPE_DESCRIPTOR",
|
||||
"SHAPE_UNREADABLE",
|
||||
"STATE_UNREADABLE",
|
||||
"CatalogEntry",
|
||||
"CatalogRoot",
|
||||
|
||||
@@ -24,6 +24,13 @@ 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".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,7 +50,7 @@ from orisys_app_shared.paths import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from orisys.sensors import ResolvedSensorProfile
|
||||
from orisys.sensors import Issue, ResolvedSensorProfile
|
||||
|
||||
#: Which root an entry came from.
|
||||
ORIGIN_SHIPPED = "shipped"
|
||||
@@ -70,6 +77,45 @@ KNOWN_FAMILY_ORDER: tuple[str, ...] = ("box", "mini", "finger", "finger-mems")
|
||||
#: the SDK's own :attr:`ValidationReport.state` (``ok`` / ``degraded`` / ``failed``).
|
||||
STATE_UNREADABLE = "invalid"
|
||||
|
||||
#: How each origin reads in the picker label. ``fallback`` is still the operator's own bundles:
|
||||
#: which directory the writable root resolved to is a tooltip fact, not a picker distinction.
|
||||
ORIGIN_LABELS: dict[str, str] = {
|
||||
ORIGIN_SHIPPED: "Shipped",
|
||||
ORIGIN_USER: "User",
|
||||
ORIGIN_FALLBACK: "User",
|
||||
}
|
||||
|
||||
#: How the SDK read a bundle. Stable identifiers, so a surface that has to translate the fact
|
||||
#: (or branch on it) never matches on a sentence.
|
||||
SHAPE_DESCRIPTOR = "descriptor"
|
||||
SHAPE_COMPATIBILITY = "compatibility"
|
||||
SHAPE_UNREADABLE = "unreadable"
|
||||
|
||||
_SHAPE_NOTES: dict[str, str] = {
|
||||
SHAPE_DESCRIPTOR: "descriptor bundle",
|
||||
SHAPE_COMPATIBILITY: "compatibility bundle, no descriptor",
|
||||
SHAPE_UNREADABLE: "no readable bundle",
|
||||
}
|
||||
|
||||
#: What the SDK's verdict *means*, said rather than named. The SDK's own state word is kept
|
||||
#: beside it because the tooltip is where the technical facts have to survive; on its own,
|
||||
#: "degraded" reads as a defect report about a healthy bundle.
|
||||
_STATE_NOTES: dict[str, str] = {
|
||||
"ok": "processing can start",
|
||||
"degraded": "processing can start; something optional is missing",
|
||||
"failed": "processing cannot start",
|
||||
STATE_UNREADABLE: "no bundle could be read here",
|
||||
}
|
||||
|
||||
#: The SDK's own issue codes whose operator-facing meaning is a capability, not the finding.
|
||||
#: A code that is not listed falls back to the SDK's message, so a renamed code cannot silently
|
||||
#: drop a warning.
|
||||
_CAPABILITY_NOTES: dict[str, str] = {
|
||||
"calibration.absent": (
|
||||
"no calibration selected — calibrated force is unavailable; raw force is available"
|
||||
),
|
||||
}
|
||||
|
||||
_WRITE_PROBE_PREFIX = ".orisys-write-probe"
|
||||
|
||||
|
||||
@@ -89,6 +135,18 @@ def _sort_key(path: Path) -> str:
|
||||
return path.name.lower()
|
||||
|
||||
|
||||
def _sentence(issue: Issue) -> str:
|
||||
"""One SDK finding as a sentence an operator can read.
|
||||
|
||||
The code goes: ``calibration.applicability-unknown`` is a name, and the message beside it is
|
||||
already the sentence. A warning whose meaning is a capability is replaced by that capability
|
||||
instead of the finding, so the operator reads what the bundle will and will not give. The
|
||||
file it is about stays — that is a fact, not a code.
|
||||
"""
|
||||
text = _CAPABILITY_NOTES.get(issue.code, issue.message)
|
||||
return f"{text} [{issue.path}]" if issue.path is not None else text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserRootDecision:
|
||||
"""Where the writable user bundles live, and why."""
|
||||
@@ -139,8 +197,11 @@ class CatalogEntry:
|
||||
valid: bool
|
||||
state: str
|
||||
reason: str = ""
|
||||
errors: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
#: The SDK's own findings, kept as issues rather than as rendered strings: the tooltip owes
|
||||
#: the operator a sentence per warning, and a code whose meaning is a capability has to be
|
||||
#: recognisable to be replaced by it.
|
||||
errors: tuple[Issue, ...] = ()
|
||||
warnings: tuple[Issue, ...] = ()
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
#: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``.
|
||||
duplicates: tuple[str, ...] = ()
|
||||
@@ -156,6 +217,60 @@ class CatalogEntry:
|
||||
"""The family group this entry is listed under; never empty."""
|
||||
return self.product_family or FAMILY_UNIDENTIFIED
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def origin_label(self) -> str:
|
||||
"""``Shipped`` or ``User``: where this configuration came from."""
|
||||
return ORIGIN_LABELS.get(self.origin, self.origin)
|
||||
|
||||
@property
|
||||
def shape(self) -> str:
|
||||
"""How the SDK read this bundle: :data:`SHAPE_DESCRIPTOR` and friends.
|
||||
|
||||
``backend_source`` is the SDK's own report of where the processing backend came from,
|
||||
and only a parsed ``manifest.json`` descriptor reports ``manifest`` — so this is the
|
||||
SDK's answer, not the catalog reading the folder a second time.
|
||||
"""
|
||||
profile = self.profile
|
||||
if profile is None:
|
||||
return SHAPE_UNREADABLE
|
||||
if profile.backend_source == "manifest":
|
||||
return SHAPE_DESCRIPTOR
|
||||
return SHAPE_COMPATIBILITY
|
||||
|
||||
@property
|
||||
def shape_note(self) -> str:
|
||||
"""The bundle's shape as the sentence the tooltip shows."""
|
||||
return _SHAPE_NOTES.get(self.shape, _SHAPE_NOTES[SHAPE_UNREADABLE])
|
||||
|
||||
@property
|
||||
def selected_calibration(self) -> str:
|
||||
"""The calibration the SDK resolved for this bundle, by file name; ``""`` for none.
|
||||
|
||||
Read from the resolution rather than from the directory: a calibration file merely
|
||||
existing proves nothing, and the SDK never substitutes a different one.
|
||||
"""
|
||||
profile = self.profile
|
||||
path = None if profile is None else profile.calibration.path
|
||||
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.
|
||||
|
||||
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"
|
||||
|
||||
@property
|
||||
def duplicate(self) -> bool:
|
||||
return bool(self.duplicates)
|
||||
@@ -178,18 +293,39 @@ class CatalogEntry:
|
||||
return None
|
||||
|
||||
def label(self) -> str:
|
||||
"""One line for a picker item: family, profile, origin and validity."""
|
||||
return f"{self.family} · {self.profile_id or self.folder} · {self.origin} · {self.state}"
|
||||
"""One line for a picker item: what it is, where it came from, what it will give.
|
||||
|
||||
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.
|
||||
"""
|
||||
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}"
|
||||
|
||||
def tooltip(self) -> str:
|
||||
"""The facts behind the label, including why the entry is not startable."""
|
||||
"""The facts behind the label, 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.
|
||||
"""
|
||||
state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
|
||||
lines = [
|
||||
f"{self.display_name} ({self.ref})",
|
||||
f"{self.name} ({self.ref}) — {self.shape_note}",
|
||||
f"profile_id: {self.profile_id or 'unknown'}",
|
||||
f"product family: {self.product_family or 'unknown'}",
|
||||
f"processing backend: {self.processing_backend or 'unknown'}",
|
||||
f"origin: {self.origin} ({self.bundle_path})",
|
||||
f"state: {self.state}",
|
||||
f"SDK validation: {self.state} ({state_note})",
|
||||
f"calibration: {self.selected_calibration or 'none selected — raw force'}",
|
||||
]
|
||||
blocked = self.start_blocked_reason
|
||||
if blocked:
|
||||
@@ -197,9 +333,11 @@ class CatalogEntry:
|
||||
# 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: {text}" for text in self.errors if text not in self.reason
|
||||
f"error: {_sentence(issue)}"
|
||||
for issue in self.errors
|
||||
if str(issue) not in self.reason
|
||||
)
|
||||
lines.extend(f"warning: {text}" for text in self.warnings)
|
||||
lines.extend(f"warning: {_sentence(issue)}" for issue in self.warnings)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -436,7 +574,10 @@ def _resolved_entry(
|
||||
) -> CatalogEntry:
|
||||
"""An entry the SDK resolved, valid or rejected, with its verdict kept whole."""
|
||||
validation = profile.validation
|
||||
errors = tuple(str(issue) for issue in validation.errors)
|
||||
# The issues are kept as the SDK objects, not stringified here: the tooltip owes a sentence
|
||||
# per warning, and the one place the code belongs is the SDK's own reason, which is what
|
||||
# "Cannot start" quotes.
|
||||
errors = tuple(validation.errors)
|
||||
return CatalogEntry(
|
||||
key=str(profile.sensor_json_path),
|
||||
folder=folder.name,
|
||||
@@ -449,9 +590,9 @@ def _resolved_entry(
|
||||
processing_backend=profile.processing_backend,
|
||||
valid=validation.usable,
|
||||
state=validation.state,
|
||||
reason="; ".join(errors),
|
||||
reason="; ".join(str(issue) for issue in errors),
|
||||
errors=errors,
|
||||
warnings=tuple(str(issue) for issue in validation.warnings),
|
||||
warnings=tuple(validation.warnings),
|
||||
diagnostics=tuple(profile.diagnostics),
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays liste
|
||||
with the SDK's own reason.
|
||||
|
||||
The two functions below stay the picker's compatibility surface. The picker itself uses the
|
||||
catalog object directly, so it can show each entry's family, profile, origin and validity.
|
||||
catalog object directly, so it can show each entry's name, origin and calibration selection,
|
||||
with the identity and the SDK's verdict in the tooltip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -4,11 +4,15 @@ The catalog replaced two rules that were wrong in the same direction — a hard-
|
||||
list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build
|
||||
a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy
|
||||
``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed
|
||||
``manifest.json``, a bundle whose declared asset is missing, and two bundles sharing one
|
||||
``profile_id``.
|
||||
``manifest.json``, a bundle whose declared asset is missing, a bundle with a calibration
|
||||
selected, and two bundles sharing one ``profile_id``.
|
||||
|
||||
The tree is built here, in the test's own temporary directory: a committed fixture would be a
|
||||
second copy of the bundle format that could drift from the real one without anyone noticing.
|
||||
|
||||
The labels are asserted here too, because they are the catalog's whole operator-facing
|
||||
contract: what a configuration is, where it came from and what it will give — never a
|
||||
``profile_id``, an ``ok``/``degraded``/``failed`` verdict, or the word ``legacy``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +37,19 @@ from orisys_app_shared.sensors import (
|
||||
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
|
||||
TRACKER_OK = {"algorithm": "dis"}
|
||||
|
||||
#: A calibration that loads cleanly and declares the family it belongs to, so a selected
|
||||
#: calibration produces no warning at all: the shape an operator should meet with no remark.
|
||||
CALIBRATION = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {"points": [[0, 0], [100, 1.0]]},
|
||||
"shear_x": {"points": [[0, 0], [100, 1.0]]},
|
||||
"shear_y": {"points": [[0, 0], [100, 1.0]]},
|
||||
},
|
||||
"meta": {"sensor_type": "mini", "sn": "FIXTURE-1"},
|
||||
}
|
||||
|
||||
|
||||
def _config(
|
||||
*, declared_type: str | None = None, stitcher: bool = False
|
||||
@@ -53,7 +70,7 @@ def _descriptor(profile_id: str, **overrides: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"profile_id": profile_id,
|
||||
"display_name": profile_id,
|
||||
"display_name": "Mini Lab",
|
||||
"product_family": "mini",
|
||||
"processing_backend": "single_camera",
|
||||
"sensor_json": "sensor.json",
|
||||
@@ -93,11 +110,16 @@ def trees(tmp_path):
|
||||
user / "missing-assets" / "manifest.json",
|
||||
_descriptor(
|
||||
"orisys.finger.missing-r1",
|
||||
display_name="Finger R01 (map missing)",
|
||||
product_family="finger",
|
||||
processing_backend="uv_unroll",
|
||||
computational_assets={"unroll_map": "assets/not-here.npz"},
|
||||
),
|
||||
)
|
||||
# A descriptor bundle with a calibration selected: the valid shape.
|
||||
_write(user / "mini-cal" / "sensor.json", _config())
|
||||
_write(user / "mini-cal" / "manifest.json", _descriptor("orisys.mini.cal-r1"))
|
||||
_write(user / "mini-cal" / "force_calibration.json", CALIBRATION)
|
||||
# Two bundles declaring one profile_id.
|
||||
for name in ("dup-a", "dup-b"):
|
||||
_write(user / name / "sensor.json", _config())
|
||||
@@ -113,6 +135,7 @@ def test_every_bundle_shape_is_discovered_with_its_origin(trees):
|
||||
"shipped/box",
|
||||
"shipped/calib-only",
|
||||
"user/mini-lab",
|
||||
"user/mini-cal",
|
||||
"user/broken-manifest",
|
||||
"user/missing-assets",
|
||||
"user/dup-a",
|
||||
@@ -233,13 +256,85 @@ def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
|
||||
assert (entry.start_blocked_reason is None) == expected, entry.ref
|
||||
|
||||
|
||||
def test_the_label_states_family_profile_origin_and_validity(trees):
|
||||
def test_the_label_states_what_it_is_where_it_came_from_and_what_it_gives(trees):
|
||||
entry = trees.by_ref("user/mini-lab")
|
||||
assert entry is not None
|
||||
assert entry.label() == "Mini Lab · User · No calibration selected — raw force"
|
||||
|
||||
|
||||
def test_a_startable_label_carries_no_identity_and_no_severity_word(trees):
|
||||
"""What an operator chooses between is products and outcomes, not internal names."""
|
||||
for entry in trees.entries:
|
||||
if not entry.valid:
|
||||
continue
|
||||
label = entry.label()
|
||||
assert entry.profile_id not in label, label
|
||||
for word in ("ok", "degraded", "failed", "legacy"):
|
||||
assert word not in label, f"{word!r} reached the row: {label}"
|
||||
|
||||
|
||||
def test_no_calibration_selected_reads_as_raw_force_not_as_a_defect(trees):
|
||||
entry = trees.by_ref("user/mini-lab")
|
||||
assert entry is not None
|
||||
# The SDK reports the absent calibration as a warning, which is the state the row used to
|
||||
# read as "degraded" — a severity word that never said "raw force" or "not selected".
|
||||
assert entry.state == "degraded"
|
||||
assert "No calibration selected — raw force" in entry.label()
|
||||
assert entry.selected_calibration == ""
|
||||
|
||||
|
||||
def test_a_selected_calibration_reads_as_a_selection(trees):
|
||||
entry = trees.by_ref("user/mini-cal")
|
||||
assert entry is not None and entry.valid, entry.reason if entry else "missing"
|
||||
assert entry.state == "ok", entry.reason
|
||||
assert entry.selected_calibration == "force_calibration.json"
|
||||
# Named, and said as something selected — never as a property of the bundle ...
|
||||
assert entry.label() == "Mini Lab · User · Calibration: force_calibration.json"
|
||||
# ... and the "ok" the SDK calls that is not a word an operator needs to read.
|
||||
assert "ok" not in entry.label()
|
||||
|
||||
|
||||
def test_an_unstartable_label_says_cannot_start_with_the_sdks_own_reason(trees):
|
||||
entry = trees.by_ref("user/missing-assets")
|
||||
label = entry.label()
|
||||
assert "finger" in label
|
||||
assert entry.profile_id in label
|
||||
assert ORIGIN_USER in label
|
||||
assert entry.state in label
|
||||
assert entry is not None and not entry.valid
|
||||
assert entry.label() == f"Finger R01 (map missing) · Cannot start: {entry.reason}"
|
||||
assert "unroll_map" in entry.label()
|
||||
|
||||
|
||||
def test_a_duplicate_label_names_the_twin(trees):
|
||||
first = trees.by_ref("user/dup-a")
|
||||
second = trees.by_ref("user/dup-b")
|
||||
assert first is not None and second is not None
|
||||
assert first.label() == "Mini Lab · User · Also provided by user/dup-b"
|
||||
assert second.label() == "Mini Lab · User · Also provided by user/dup-a"
|
||||
|
||||
|
||||
def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(trees):
|
||||
"""The facts stop being the first thing read; they do not stop being available."""
|
||||
entry = trees.by_ref("user/mini-lab")
|
||||
assert entry is not None
|
||||
tooltip = entry.tooltip()
|
||||
assert entry.profile_id in tooltip
|
||||
assert "descriptor bundle" in tooltip
|
||||
assert f"SDK validation: {entry.state}" in tooltip
|
||||
assert "processing can start" in tooltip, "the verdict word is left unexplained"
|
||||
assert "calibration: none selected — raw force" in tooltip
|
||||
# A finding is a sentence: the code is dropped, the capability it names is not.
|
||||
assert "calibration.absent" not in tooltip
|
||||
assert "calibrated force is unavailable; raw force is available" in tooltip
|
||||
|
||||
|
||||
def test_the_tooltip_of_a_legacy_bundle_says_its_shape(trees):
|
||||
entry = trees.by_ref("shipped/box")
|
||||
assert entry is not None
|
||||
tooltip = entry.tooltip()
|
||||
assert "compatibility bundle, no descriptor" in tooltip
|
||||
assert entry.profile_id in tooltip
|
||||
# The code the row lost is not lost here: it is rendered as what it means, and the file it
|
||||
# is about is kept.
|
||||
assert "descriptor.legacy" not in tooltip
|
||||
assert "no manifest.json" in tooltip
|
||||
assert "built-in profile table" in tooltip
|
||||
|
||||
|
||||
def test_the_root_decision_names_the_root_in_use():
|
||||
|
||||
Reference in New Issue
Block a user