feat: 为每个 Sensor 绑定只读 SensorContext

构造时一次绑定 SN、配置、资产和当前生效的标定:只接受显式给出的 SN,不做猜测;标定路径只取当前生效的标定,清除后为 None;换标定时整体替换。帧身份复用 stream-contract 的 FrameIdentity。load_config 改为复用 resolve_config_source,使记录的配置来源与实际读取的文件一致,并补一致性测试。已发布 Sensor 数值和资产包解析不变。回归 275 项、lint 通过。
This commit is contained in:
ykcheungab
2026-10-21 11:04:02 +08:00
parent f6f3c45285
commit 474dbf5a64
7 changed files with 899 additions and 21 deletions
+50 -19
View File
@@ -283,6 +283,48 @@ def resolve_sensor_config_path(name: str) -> Path | None:
return bundle.sensor_json if bundle is not None else None
def resolve_config_source(config_name_or_path) -> Path | None:
"""Return the JSON file :func:`load_config` would read for this name/path.
Uses the same lookup order as ``load_config``: exact file, additive sensor
bundle, config directory stem, then the legacy ``"name"`` field scan. Returns
``None`` when the name has no file (never inventing a path), and follows
aliases exactly like loading does so a recorded path is the one that was
actually read.
"""
config_path = Path(config_name_or_path)
if config_path.exists() and config_path.is_file():
return config_path.resolve()
if config_path.is_absolute():
return None
requested = str(config_name_or_path)
candidates = [requested]
alias = CONFIG_ALIASES.get(requested)
if alias and alias not in candidates:
candidates.append(alias)
for name in candidates:
bundle = resolve_sensor_bundle(name)
if bundle is not None:
return bundle.sensor_json
for name in candidates:
direct = CONFIG_DIR / f"{name}.json"
if direct.exists():
return direct.resolve()
for candidate in _config_files():
try:
data = _load_json(candidate)
except Exception: # noqa: BLE001, S112 - unreadable configs are skipped during the scan
continue
if data.get("name") in candidates:
return candidate.resolve()
return None
def resolve_sensor_asset_dir(name: str) -> Path | None:
"""Return the default asset directory for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name)
@@ -402,6 +444,10 @@ def load_config(config_name_or_path):
2. Additive sensor bundle ``sensors/<name>/sensor.json``
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
The lookup itself lives in :func:`resolve_config_source`, so a caller that
records which file was read (for example ``SensorContext.config_source``)
cannot drift from what was actually loaded here.
"""
config_path = Path(config_name_or_path)
@@ -413,31 +459,16 @@ def load_config(config_name_or_path):
if config_path.is_absolute():
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
source = resolve_config_source(config_name_or_path)
if source is not None:
return _load_json(source)
requested = str(config_name_or_path)
candidates = [requested]
alias = CONFIG_ALIASES.get(requested)
if alias and alias not in candidates:
candidates.append(alias)
for name in candidates:
bundle = resolve_sensor_bundle(name)
if bundle is not None:
return _load_json(bundle.sensor_json)
for name in candidates:
direct = CONFIG_DIR / f"{name}.json"
if direct.exists():
return _load_json(direct)
# Backward-compatible alias resolution: support old names via JSON "name" field.
for candidate in _config_files():
try:
data = _load_json(candidate)
except Exception: # noqa: S112 - skip unreadable config during alias scan
continue
if data.get("name") in candidates:
return data
available = [f.stem for f in _config_files()]
available_str = ", ".join(available) if available else "none"
raise FileNotFoundError(
+54
View File
@@ -11,6 +11,7 @@ import time
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING
import cv2
import numpy as np
@@ -41,6 +42,9 @@ from .capture import (
open_capture,
)
if TYPE_CHECKING:
from .sensors.context import SensorContext
@dataclass(frozen=True)
class FrameMeasurement:
@@ -229,6 +233,10 @@ class Sensor:
self.bind_calibration_context(build_context(
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
))
# One read-only binding of SN/config/assets/cal_path per instance. The
# later read-only SensorContext contract (T04) is additive; a binding
# problem is reported on the context, never by changing construction.
self._bind_sensor_context()
if self.verbose:
if self.calibration is not None and self.calibration.source_path is not None:
@@ -1435,6 +1443,48 @@ class Sensor:
),
}
def _bind_sensor_context(self) -> None:
"""Bind this instance's read-only SN/config/assets/cal_path context.
Called once during construction and again only when the force
calibration is explicitly replaced. The binding is local to this
instance; no state is cached or shared between sensors, and an
unexpected binding problem is reported on the context instead of
changing whether a published Sensor can be constructed. Partially
initialised Sensor doubles (tests build some with ``__new__``) are
tolerated: their missing fields bind as ``None``.
"""
from .sensors.context import build_sensor_context, fallback_sensor_context
config_name = getattr(self, "config_name", "")
config = getattr(self, "_config", None)
device_id = getattr(self, "device_id", None)
calibration = getattr(self, "calibration", None)
try:
bundle_candidates = self._bundle_name_candidates()
except Exception: # noqa: BLE001 - a test double may not carry the config
bundle_candidates = []
try:
self._sensor_context = build_sensor_context(
config_name=config_name,
config=config if isinstance(config, dict) else {},
device_id=device_id,
calibration=calibration,
bundle_candidates=bundle_candidates,
)
except Exception as exc: # noqa: BLE001 - context binding must not break a Sensor
self._sensor_context = fallback_sensor_context(
config_name=config_name,
config=config,
device_id=device_id,
reason=exc,
)
@property
def context(self) -> "SensorContext":
"""Read-only SN/config/assets/calibration binding of this Sensor instance."""
return self._sensor_context
@property
def force_calibrated(self) -> bool:
"""True when at least one channel curve is loaded."""
@@ -1475,6 +1525,7 @@ class Sensor:
self._calibration_context = None
self._force_readout.reset()
self._apply_force_filter()
self._bind_sensor_context()
return
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
selected = cal
@@ -1509,6 +1560,9 @@ class Sensor:
if not self._uses_calibration_model():
self._force_readout.reset()
self._apply_force_filter()
# The context is a value object: an explicit calibration swap atomically
# replaces it (SN/config/assets stay the same; cal path/fingerprint update).
self._bind_sensor_context()
def _apply_force_filter(self):
convert = self.calibration.convert if self.calibration is not None else None
+11
View File
@@ -53,6 +53,12 @@ from .capabilities import (
report,
unavailable,
)
from .context import (
SensorAssetBinding,
SensorContext,
build_sensor_context,
fallback_sensor_context,
)
from .descriptor import (
BACKEND_COMPOUND_EYE,
BACKEND_SINGLE_CAMERA,
@@ -100,6 +106,11 @@ __all__ = [
"Availability",
"AxisAvailability",
"Issue",
# read-only per-instance binding (T04)
"SensorContext",
"SensorAssetBinding",
"build_sensor_context",
"fallback_sensor_context",
# descriptor
"BundleDescriptor",
"DescriptorError",
+353
View File
@@ -0,0 +1,353 @@
"""Read-only ``SensorContext``: the SN, configuration, assets and calibration path
bound once for one Sensor instance.
Stage-1 architecture (K11.1, sensor-context task T04) makes one object own what
must not be re-guessed per frame: which physical sensor this is, which
``sensor.json``/configuration it runs, which bundle assets were selected, and
which force calibration was chosen. The context is a frozen value object; a
``Sensor`` binds it once during construction (and atomically replaces it only
when its calibration is explicitly swapped), so two Sensor instances cannot
share or overwrite each other's identity.
Identity fields are not duplicated here: :meth:`SensorContext.frame_identity`
builds the frozen :class:`~orisys.stream_contract.FrameIdentity` from stage 1,
so the per-frame stream contract remains the single home for frame identity and
clock fields.
The context is plain data (paths as strings, hashes, tuples): no Qt, no device
handle, no mutable mapping. ``build_sensor_context`` is tolerant by design - a
bundle that cannot be described is reported in ``diagnostics`` instead of
changing a published Sensor's construction behaviour.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .. import configs as sdk_configs
from ..stream_contract import FrameIdentity
from .descriptor import parse_descriptor
from .legacy import backend_from_config
__all__ = [
"SensorAssetBinding",
"SensorContext",
"build_sensor_context",
"fallback_sensor_context",
]
#: Manifest keys that describe the bundle itself rather than an asset file.
_MANIFEST_META_KEYS = frozenset({"name", "sensor_json", "version", "schema_version"})
def _json_digest(value: Any) -> tuple[str, str | None]:
"""Canonical SHA-256 of a JSON value plus an optional diagnostic."""
try:
text = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
except (TypeError, ValueError) as exc: # a config that is not plain JSON
return (
hashlib.sha256(str(value).encode("utf-8")).hexdigest(),
f"config is not plain JSON for fingerprinting: {exc}",
)
return hashlib.sha256(text.encode("utf-8")).hexdigest(), None
def _file_digest(path: Path) -> str | None:
try:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
except OSError:
return None
def _relative_posix(path: Path, root: Path) -> str | None:
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except (OSError, ValueError):
return None
def _manifest_assets(
bundle: sdk_configs.SensorBundle,
manifest_raw: Mapping[str, Any] | None,
descriptor,
diagnostics: list[str],
) -> tuple[SensorAssetBinding, ...]:
if manifest_raw is None:
return ()
if descriptor is not None:
# Descriptor-shaped manifest: bind the declared computational assets.
bindings = []
for declared in (descriptor.computational_assets, descriptor.assets):
for role, value in sorted(declared.items()):
rel = _relative_posix(Path(value), bundle.root)
if rel is None:
diagnostics.append(f"asset {role!r} escapes the bundle folder")
continue
bindings.append(SensorAssetBinding(str(role), rel))
return tuple(bindings)
# Legacy manifest (for example ``sensors/finger/manifest.json``): bind the
# declared asset directory and any single-file entries it names.
bindings = []
for role, value in sorted(manifest_raw.items()):
if (
role in _MANIFEST_META_KEYS
or not isinstance(value, str)
or not value.strip()
):
continue
candidate = (bundle.root / Path(value)).resolve()
rel = _relative_posix(candidate, bundle.root)
if rel is None:
diagnostics.append(f"manifest entry {role!r} escapes the bundle folder")
continue
bindings.append(SensorAssetBinding(str(role), rel))
return tuple(bindings)
@dataclass(frozen=True)
class SensorAssetBinding:
"""One bundle asset the instance was bound to: a role and a bundle-relative path."""
role: str
path: str
@dataclass(frozen=True)
class SensorContext:
"""The immutable identity/config/assets/calibration binding of one Sensor instance.
``sensor_id`` is only the **explicitly supplied** SN (``device_id``); it is
``None`` when the caller did not bind one. The context never guesses an SN
from enumeration order, bundle names or reserved camera indices - a frame
that needs a stream identity therefore fails loudly instead of being
attributed to the wrong device. ``calibration_path`` and
``calibration_fingerprint`` describe the calibration currently in effect:
``set_calibration(None)`` leaves them ``None``/``"none"`` even when the
Sensor was constructed with an explicit path.
"""
sensor_id: str | None
product_family: str | None
processing_backend: str
config_name: str
config_source: str | None
config_fingerprint: str
bundle_root: str | None
manifest_digest: str | None
assets: tuple[SensorAssetBinding, ...]
calibration_path: str | None
calibration_fingerprint: str
diagnostics: tuple[str, ...] = field(default_factory=tuple)
def frame_identity(
self,
*,
session_id: str,
frame_id: int,
source_frame_id: int | None,
acquisition_id: str | None = None,
sync_group_id: str | None = None,
) -> FrameIdentity:
"""Build this stream's frozen frame identity from the bound SN.
The capture clock is not part of ``SensorContext``: it belongs to the
frame header and is produced with the frame. This method reuses
:class:`~orisys.stream_contract.FrameIdentity` rather than defining a
second identity shape.
"""
if self.sensor_id is None:
raise ValueError(
"SensorContext has no explicit sensor_id (SN); construct the Sensor "
"with device_id (or bind one) before building a frame identity"
)
return FrameIdentity(
sensor_id=self.sensor_id,
session_id=session_id,
frame_id=frame_id,
source_frame_id=source_frame_id,
acquisition_id=acquisition_id,
sync_group_id=sync_group_id,
)
def as_dict(self) -> dict[str, Any]:
"""JSON-serialisable snapshot for records and diagnostics."""
return {
"sensor_id": self.sensor_id,
"product_family": self.product_family,
"processing_backend": self.processing_backend,
"config_name": self.config_name,
"config_source": self.config_source,
"config_fingerprint": self.config_fingerprint,
"bundle_root": self.bundle_root,
"manifest_digest": self.manifest_digest,
"assets": [
{"role": asset.role, "path": asset.path} for asset in self.assets
],
"calibration_path": self.calibration_path,
"calibration_fingerprint": self.calibration_fingerprint,
"diagnostics": list(self.diagnostics),
}
def build_sensor_context(
*,
config_name: str | Path,
config: Mapping[str, Any],
device_id: str | None = None,
calibration: Any = None,
bundle_candidates: tuple[str, ...] | list[str] = (),
) -> SensorContext:
"""Bind one Sensor's SN, configuration identity, assets and calibration path.
Every lookup is explicit and local to this call; nothing is shared between
instances. Optional bundle description problems are collected in
``diagnostics`` and never raised, so adding a context cannot change whether a
published Sensor can be constructed.
"""
diagnostics: list[str] = []
cfg = config if isinstance(config, Mapping) else {}
config_fingerprint, digest_problem = _json_digest(dict(cfg))
if digest_problem:
diagnostics.append(digest_problem)
config_source: str | None = None
try:
source = sdk_configs.resolve_config_source(config_name)
except Exception as exc: # noqa: BLE001 - diagnostics only
source = None
diagnostics.append(f"config source could not be resolved: {exc}")
if source is not None:
config_source = str(Path(source).resolve())
names = [str(candidate) for candidate in bundle_candidates]
product_family = next(
(name for name in names if name in sdk_configs.PRODUCT_BUNDLES), None
)
bundle = None
for name in names:
try:
bundle = sdk_configs.resolve_sensor_bundle(name)
except Exception as exc: # noqa: BLE001 - diagnostics only
diagnostics.append(f"bundle {name!r} could not be resolved: {exc}")
continue
if bundle is not None:
break
processing_backend = backend_from_config(cfg)
bundle_root: str | None = None
manifest_digest: str | None = None
assets: tuple[SensorAssetBinding, ...] = ()
if bundle is not None:
bundle_root = str(bundle.root)
if product_family is None and bundle.name in sdk_configs.PRODUCT_BUNDLES:
product_family = bundle.name
manifest_raw: Mapping[str, Any] | None = None
descriptor = None
if bundle.manifest_json is not None:
manifest_digest = _file_digest(bundle.manifest_json)
if manifest_digest is None:
diagnostics.append("bundle manifest.json is unreadable")
try:
loaded = json.loads(
Path(bundle.manifest_json).read_text(encoding="utf-8-sig")
)
except (OSError, json.JSONDecodeError) as exc:
diagnostics.append(f"bundle manifest.json could not be read: {exc}")
else:
if isinstance(loaded, Mapping):
manifest_raw = loaded
if loaded.get("schema_version") is not None:
try:
descriptor = parse_descriptor(bundle.root)
except Exception as exc: # noqa: BLE001 - reported, never fatal
diagnostics.append(
f"bundle manifest.json is not a valid descriptor: {exc}"
)
# Legacy manifests have no backend field; the config rule that
# already drives create_frame_processor stays in force.
else:
diagnostics.append("bundle manifest.json is not a JSON object")
if descriptor is not None:
processing_backend = descriptor.processing_backend
assets = _manifest_assets(bundle, manifest_raw, descriptor, diagnostics)
else:
diagnostics.append("no additive sensor bundle resolved for this config")
calibration_path: str | None = None
calibration_fingerprint = "none"
if calibration is not None:
source_path = getattr(calibration, "source_path", None)
if source_path is not None:
calibration_path = str(Path(source_path).resolve())
fingerprint = getattr(calibration, "fingerprint", None)
if isinstance(fingerprint, str) and fingerprint:
calibration_fingerprint = fingerprint
else:
calibration_fingerprint = "unknown"
diagnostics.append(
f"{type(calibration).__name__} exposes no content fingerprint"
)
sensor_id = None
if device_id is not None:
text = str(device_id).strip()
sensor_id = text or None
if sensor_id is None:
diagnostics.append("device_id was empty; no SN is bound")
return SensorContext(
sensor_id=sensor_id,
product_family=product_family,
processing_backend=processing_backend,
config_name=str(config_name),
config_source=config_source,
config_fingerprint=config_fingerprint,
bundle_root=bundle_root,
manifest_digest=manifest_digest,
assets=assets,
calibration_path=calibration_path,
calibration_fingerprint=calibration_fingerprint,
diagnostics=tuple(diagnostics),
)
def fallback_sensor_context(
*,
config_name: str | Path,
config: Mapping[str, Any] | None,
device_id: str | None,
reason: BaseException,
) -> SensorContext:
"""A minimal context used when binding itself fails unexpectedly.
The Sensor keeps working; the failure and its cause are visible on the
context instead of crashing a published construction path.
"""
config_fingerprint, _ = _json_digest(
dict(config) if isinstance(config, Mapping) else {}
)
backend = (
backend_from_config(config) if isinstance(config, Mapping) else "single_camera"
)
sensor_id = str(device_id).strip() if device_id is not None else None
return SensorContext(
sensor_id=sensor_id or None,
product_family=None,
processing_backend=backend,
config_name=str(config_name),
config_source=None,
config_fingerprint=config_fingerprint,
bundle_root=None,
manifest_digest=None,
assets=(),
calibration_path=None,
calibration_fingerprint="none",
diagnostics=(f"context binding failed: {type(reason).__name__}: {reason}",),
)