修复回放标定选择与曲线显示数据保留

让标定选择进入处理和缓存,避免显示设置清空曲线。102 项针对性检查通过,全量门禁仍有停止时序、模型尺寸与原生崩溃问题。
This commit is contained in:
ykcheungab
2026-10-19 16:30:36 +08:00
parent 768f560820
commit 9d4563e929
12 changed files with 478 additions and 97 deletions
@@ -63,6 +63,7 @@ __all__ = [
"SyntheticWorkspace",
"VideoFacts",
"build_workspace",
"calibration_identity",
"is_synthetic",
"profiles_for_video",
"read_synthetic",
@@ -325,7 +326,7 @@ def _session_id(identity: str) -> int:
return int(identity[:12], 16) % (2**31)
def _calibration_identity(path: Path | None) -> str:
def calibration_identity(path: Path | None) -> str:
"""A calibration's key component: its name plus a digest of its own content.
The *content* is what decides the numbers the chain computes, so a coefficient file
@@ -384,7 +385,7 @@ def session_document(
"files": {"video": [VIDEO_FILENAME]},
"preroll_frames": 0,
"preroll_segment": "",
"calibration_id": _calibration_identity(calibration),
"calibration_id": calibration_identity(calibration),
"depth": {"stored": True, "replay": "analysis"},
"synthetic": {
"generator": GENERATOR,
@@ -168,21 +168,21 @@ class ChainSpec:
flip_source_vertical: bool = True
mirror: bool = False
#: An explicit force-calibration file, or ``None`` for whatever the configuration's
#: own bundle resolves. It is *not* part of :meth:`identity`, deliberately: a recording
#: was taken under one calibration and the identity of *what it is* must not move when
#: a different curve is tried against it. The calibration half of the invalidation key
#: is the recording's own ``calibration_id`` (see
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
#: the chosen file's content.
#: own bundle resolves. The chosen content belongs to the calibration half of
#: the result key, separate from the recording and sensor configuration identity.
calibration: Path | None = None
#: Device serial the operator confirmed for ``calibration``. Passed with ``cal_path``
#: so the SDK can reject a file whose ``sn`` is a different sensor. Not part of
#: :meth:`identity`: a mismatch fails the open, and a match does not change the file.
device_id: str | None = None
#: Explicit raw-force selection, distinct from resolving the bundle default.
calibration_disabled: bool = False
def __post_init__(self) -> None:
if self.sensor_type not in ("finger", "box"):
raise ValueError(f"unsupported sensor type {self.sensor_type!r}")
if self.calibration_disabled and self.calibration is not None:
raise ValueError("a disabled calibration cannot name a file")
@property
def config_sha256(self) -> str:
@@ -215,6 +215,7 @@ def chain_spec_for(
export_dir: str | Path | None = None,
calibration: str | Path | None = None,
device_id: str | None = None,
calibration_disabled: bool = False,
) -> ChainSpec:
"""The chain a recording was made with, unless the caller names a different one.
@@ -222,8 +223,8 @@ def chain_spec_for(
recorded ``device.config_ref`` is the configuration the footage was taken under,
not whatever happens to be installed now. ``calibration`` is the one fact a *recording*
cannot supply — it names the curve in force at record time, not the file it lived in —
so a caller that knows which file it means (the generated workspace for a bare video)
passes it, and everything else keeps the bundle's own resolution.
so an explicit setup passes that file. Otherwise the bundle resolves its default,
unless the caller explicitly disables calibration.
"""
device = dict(session.get("device") or {})
kind = str(sensor_type or device.get("sensor_type") or "")
@@ -246,6 +247,7 @@ def chain_spec_for(
export_dir=bundle,
calibration=None if calibration is None else Path(str(calibration)),
device_id=serial,
calibration_disabled=calibration_disabled,
)
@@ -321,6 +323,8 @@ class _OpenChain:
if self.spec.device_id:
kwargs["device_id"] = str(self.spec.device_id)
self._runtime = FingerRuntime.open(**kwargs)
if self.spec.calibration_disabled:
self._runtime.set_calibration(None)
self.sensor = self._runtime.tactile
self._read = self._runtime.read_source
self._feed = self._runtime.process_frame
@@ -341,7 +345,10 @@ class _OpenChain:
),
verbose=False,
backend="auto",
device_id=self.spec.device_id if self.spec.calibration is not None else None,
)
if self.spec.calibration_disabled:
sensor.set_calibration(None)
self.sensor = sensor
raw_read = getattr(sensor, "_read_frame", None)
if not callable(raw_read):
@@ -524,6 +531,7 @@ class AnalysisService:
max_bytes: int = DEFAULT_MAX_BYTES,
working_directory: str | Path | None = None,
store_force: bool = False,
unloaded_reference_frames: int | None = None,
on_progress: Callable[[Progress], None] | None = None,
cancelled: threading.Event | None = None,
) -> None:
@@ -546,11 +554,10 @@ class AnalysisService:
self.working_directory = (
None if working_directory is None else Path(working_directory)
)
#: Whether each frame also stores the chain's own force sample. Off for a recording,
#: whose force is a *measurement in its own file* (``forces.csv``) and must not be
#: overwritten in the reader's mind by a recomputation of it; on for a bare video,
#: where the chain's force is the only force there is (see ``bare_video``).
#: Store a distinct recomputed trace for a bare video or an explicitly
#: reconfigured recording; never rewrite the recording's forces.csv.
self.store_force = bool(store_force)
self.unloaded_reference_frames = unloaded_reference_frames
self.on_progress = on_progress
self.cancelled = cancelled
self._reader: RecordingReader | None = None
@@ -618,12 +625,20 @@ class AnalysisService:
or dict(session.get("calibration") or {}).get("version")
or ""
)
zero_frames = int((session.get("synthetic") or {}).get("unloaded_reference_frames", 0))
if zero_frames not in (0, 30):
raise ValueError("unloaded reference must contain exactly 30 frames")
if self.chain.calibration_disabled:
calibration = ""
elif self.chain.calibration is not None:
from orisys_app_shared.analysis.bare_video import calibration_identity
calibration = calibration_identity(self.chain.calibration)
zero_frames = self._zero_reference_frames()
key = result_key(
recording=identity,
sensor_config=self.chain.identity() + (";unloaded-reference=30" if zero_frames else ""),
sensor_config=(
self.chain.identity()
+ (";unloaded-reference=30" if zero_frames else "")
+ (";force=recomputed" if self.store_force else "")
),
calibration=calibration,
reference_frame=self.reference_frame,
)
@@ -813,7 +828,7 @@ class AnalysisService:
from orisys_app_shared.acquisition.sdk_worker import process_chain
provenance = state.provenance
zero_frames = int((self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0))
zero_frames = self._zero_reference_frames()
for position, number in enumerate(state.segments):
if state.reasons or self._stop_requested():
if self._stop_requested():
@@ -934,6 +949,15 @@ class AnalysisService:
raise ValueError("video ended before 30 unloaded reference frames were available")
# ------------------------------------------------------------------- internals
def _zero_reference_frames(self) -> int:
value = self.unloaded_reference_frames
if value is None:
value = (self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0)
frames = int(value)
if frames not in (0, 30):
raise ValueError("unloaded reference must contain exactly 30 frames")
return frames
def _read_force(self, chain: _OpenChain) -> ForceSample | None:
"""This frame's force sample, or ``None`` when the pass was not asked for one.
@@ -1169,10 +1193,8 @@ class AnalysisService:
sources={
"flow": "recomputed",
"depth": "recomputed",
# The one channel whose answer depends on what the input *is*. Beside a
# recording, ``forces.csv`` is the measurement and the result stores none of
# it; over a bare video there is no CSV, so the chain's own recomputation is
# what a chart can draw — and it says so.
# Bare videos and explicitly reconfigured recordings store a separate
# recomputed trace. Other passes leave force display to forces.csv.
"forces": "recomputed"
if self.store_force
else "recorded",
@@ -63,6 +63,7 @@ class MetricHistory:
lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float = 0.5,
max_fps: float = DEFAULT_MAX_FPS,
time_origin: float | None = None,
):
self.channels = int(channels)
self.window_sec = float(window_sec)
@@ -80,6 +81,8 @@ class MetricHistory:
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self._capacity = self.max_points
# None uses the first sample; sources already in session seconds pass zero.
self._time_origin = time_origin
self.reset()
def set_window_sec(self, window_sec: float) -> None:
@@ -96,7 +99,7 @@ class MetricHistory:
(self._capacity, 1 + self.channels), np.nan, dtype=np.float64
)
self.sample_count = 0
self._t0: float | None = None
self._t0: float | None = self._time_origin
self._y_range_smooth: list[tuple[float, float] | None] = [
None for _ in range(self.channels)
]