将场加权标定模型接入回放分析
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
This commit is contained in:
@@ -38,10 +38,9 @@ zlib level 6。
|
||||
`(max − min) / 510`,实测 6.6e-5(该帧峰值 0.034)。这不到深度视图所用 256 级色标的一步,所以
|
||||
重建出来的帧**不可能被染成与原帧不同的颜色**。
|
||||
|
||||
稀疏存储实测过并否决:每像素 4 B 索引在最坏帧上要 910 B,而整幅量化平面只要 486 B —— 一片几乎
|
||||
全零的字节平面在 zlib 下几乎免费,像素索引不是。限制在岛内也实测过并否决:418 B 对 486 B,
|
||||
在 4,048 B 的记录里省 5.2 B/帧,代价是重建要依赖有效性掩码 keyframe(box 链路根本没有),
|
||||
而且一旦有深度值落在岛外就会**静默丢掉一个测量**。
|
||||
稀疏存储实测后否决:最坏帧每像素 4 B 索引要 910 B,整幅量化平面只要 486 B —— 几乎全零的
|
||||
字节平面在 zlib 下几乎免费。限制在岛内也否决:418 B 对 486 B,每帧省 5.2 B,却要依赖有效性
|
||||
掩码 keyframe(box 链路没有),且岛外深度值会静默丢测量。
|
||||
|
||||
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
|
||||
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
|
||||
@@ -90,7 +89,8 @@ box 链路没有 `valid_mask`,`max_arrows=400` 的上限因此按幅值排序
|
||||
`magnitude` 重新推导 `active`,不需要重建结果;只有采样**网格**(`flow_grid_spacing`、
|
||||
`flow_edge_margin`、`max_arrows`)变化才会移动候选位置,那份策略记在 provenance 里供比对而
|
||||
不塞进键里。SDK 版本同样不在键里(补丁版本不该让键变化),它记在 provenance 的 `versions`,
|
||||
由 `AnalysisReader.stale_reasons()` 报出。
|
||||
由 `AnalysisReader.stale_reasons()` 报出。场加权实验模型的键分量与逐帧质量见
|
||||
[replay-field-experiment.zh.md](replay-field-experiment.zh.md)。
|
||||
|
||||
## 4. 参考帧规则
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Studio 回放:场加权实验模型
|
||||
|
||||
用途:reference。范围:Studio 回放分析对 SDK `field_gain/1`(输入 profile
|
||||
`hhd_normal_l1_field/1`)的接线、缓存身份、逐帧质量诊断与实时拒绝边界。
|
||||
格式、完整性、失效键与播放语义分别由[replay-analysis.md](replay-analysis.md)与
|
||||
[replay-analysis-part-b.md](replay-analysis-part-b.md)负责;模型语义见
|
||||
[场模型契约](../../../orisys-sdk/docs/field-gain-model.zh.md),任务范围见
|
||||
[实施计划](../../../docs/workflow/field-weighted-calibration-plan.zh.md)。
|
||||
|
||||
## 缓存身份
|
||||
|
||||
场加权模型是新的分析行为,但不是新的二进制格式。`result_key` 的 `sensor_config` 分量在
|
||||
标定文件实际为 `field_gain/1` 时追加 `;field-weighted=1`;spatial/global 模型与旧结果的键
|
||||
不变,因此旧回放结果仍能命中。标定内容身份仍来自 schema 3 指纹(含 `pipeline.field`、
|
||||
几何与坐标附件),算法版本不因本功能整体升级。
|
||||
|
||||
provenance 另存 `field` 块:`profile`、`strategy`、实验来源、`baseline_N`、
|
||||
`reference_id`、参与统计帧数、`extrapolated_mean/max` 与**运行时实际**的
|
||||
`hhd.downsample`、`hhd.grid_shape`。缓存键只用标定指纹与配置内容哈希,不用声明值假装实际值;
|
||||
实际网格只进 provenance。
|
||||
|
||||
## 回放 pass
|
||||
|
||||
- 打开链后读 `sensor.calibration.input_profile` 判定模型;field 模型只在显式 `cal_path`
|
||||
下使用,profile 默认文件不假定为实验模型。
|
||||
- 新模型要求**单 segment 录像**:加权基线无法在段边界重建,多 segment 直接 `ValueError`
|
||||
并留下原因,不静默丢基线。
|
||||
- 空载窗口仍需操作员在分析设置里声明(30 帧);每个 pass 在参考帧 `reset` 之后、该帧
|
||||
`process_chain` 之前调用一次 `declare_unloaded_reference(reference_id="video-unloaded-<id>")`。
|
||||
SDK 累计连续 30 个有效加权 `P` 并取中位数;前 30 帧状态 `zero_unavailable`,
|
||||
没有牛顿值但保留 raw 读数。不得调用 `set_calibration_reference` 传旧 raw 标量零点,
|
||||
也不得把 `centroid_found` 当门槛。
|
||||
- 第 30 个有效帧后确认 `baseline_N` 已就绪;视频结束仍未就绪沿用现有 `ValueError` 语义。
|
||||
- 逐帧质量取自 `sensor.calibration_result.diagnostics`:`extrapolated_fraction`、
|
||||
`coverage_fraction`、`pending_reference_frames`、`baseline_N`、`reference_id`、
|
||||
`model_fingerprint`。质量是估计证据,不参与 `calibration_status`,不得因此丢弃牛顿值。
|
||||
|
||||
## 逐帧存储与读取
|
||||
|
||||
场加权质量追加在帧记录最后:`FRAME_FLAG_FIELD` 后接 `extrapolated_fraction` 与
|
||||
`coverage_fraction` 两个 float32,沿用 `FRAME_FLAG_FORCE` 的追加先例,格式版本不变。
|
||||
`AnalysisReader.frame().field_quality` 返回二元组;旧结果没有该 flag 时返回 `None`,
|
||||
上层显示“未知”,不得补零或拒绝结果。`counts.field_quality_frames` 记录带质量帧数。
|
||||
|
||||
`ForceSample.from_reading` 继续只凭 raw 有限性判断 `valid`;质量缺失或外推比例高低都不改变
|
||||
有效性。`out_of_range` 与负的基线差保留数值。
|
||||
|
||||
## 显示与实时拒绝
|
||||
|
||||
- `replay_controller` 在曲线来源与提示中使用 provenance 的 `field` 块:实验模型标签、
|
||||
加权基线、平均外推比例;缺字段显示未知。中英 locales 成对新增键。
|
||||
- 实时入口未接入该模型:`SdkAcquisitionWorker.start()`/`FingerAcquisitionWorker.start()`
|
||||
与热切换 `_consume_pending_calibration()` 在打开设备前检查显式 `cal_path`,遇
|
||||
schema 3 `field_gain/1` 抛可诊断的 `RuntimeError`;由现有运行错误处理显示。
|
||||
`calibration_service`/实时 UI 若继续重构,保留同一检查函数供调用。
|
||||
|
||||
## 验收与限制
|
||||
|
||||
回归见 `shared/tests/test_field_calibration_app.py`、`apps/studio/tests/test_field_experiment_display.py`
|
||||
及扩展现有四件套:窗口只声明一次、前 30 帧无牛顿值、第 30 帧后就绪、无接触仍有效、
|
||||
多 segment 拒绝、质量 round-trip 与旧结果未知、field 键与 spatial 键不同、实时入口拒绝。
|
||||
本次不宣称物理精度或在先模型的精度改善;基线窗口是否平稳、外推比例大小都必须随结果如实报告。
|
||||
@@ -42,6 +42,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.force.source import AnalysisCurveSource
|
||||
from orisys_app_shared.analysis.model import FIELD_PROFILE
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
from PySide6.QtWidgets import QFileDialog, QWidget
|
||||
@@ -66,6 +67,59 @@ PLAYBACK_TICK_MS = 33
|
||||
ReplayHost = ReplayShell
|
||||
|
||||
|
||||
def field_experiment_note(session: Any) -> tuple[str, str]:
|
||||
"""What a field-weighted result is, as ``(status line, tooltip)``.
|
||||
|
||||
A field result is an **experiment**: its force is an area-weighted integral of the
|
||||
HHD curl-free field with a baseline measured from a declared unloaded window, and
|
||||
part of its support may be extrapolated. The replay has to say that where the
|
||||
operator looks — the status line and the chart's tooltip — using the result's own
|
||||
provenance. A result that carries the field profile but no diagnostics (an older
|
||||
cache, or a pass that could not collect them) says *unknown*: a missing
|
||||
extrapolated fraction is not zero extrapolation.
|
||||
|
||||
Returns ``("", "")`` for anything that is not a field result, so a scalar model's
|
||||
replay is unchanged.
|
||||
"""
|
||||
reader = getattr(session, "reader", session)
|
||||
if reader is None:
|
||||
return "", ""
|
||||
provenance = getattr(reader, "provenance", None)
|
||||
field = getattr(provenance, "field", None)
|
||||
if not isinstance(field, dict) or not field:
|
||||
return "", ""
|
||||
if str(field.get("profile") or "") != FIELD_PROFILE:
|
||||
return "", ""
|
||||
frames = int(field.get("frames") or 0)
|
||||
baseline = field.get("baseline_N")
|
||||
extrapolated = field.get("extrapolated_mean")
|
||||
if frames <= 0 or baseline is None or extrapolated is None:
|
||||
return t("chart.field_unknown"), t("chart.field_unknown_tip")
|
||||
hhd = dict(field.get("hhd") or {})
|
||||
grid_shape = hhd.get("grid_shape")
|
||||
grid = (
|
||||
f"{int(grid_shape[0])}x{int(grid_shape[1])}"
|
||||
if isinstance(grid_shape, (list, tuple)) and len(grid_shape) == 2
|
||||
else "?"
|
||||
)
|
||||
downsample = hhd.get("downsample")
|
||||
note = t(
|
||||
"chart.field_experiment",
|
||||
baseline=f"{float(baseline):.3f}",
|
||||
frames=frames,
|
||||
extrapolated=f"{100.0 * float(extrapolated):.1f}",
|
||||
)
|
||||
tip = t(
|
||||
"chart.field_experiment_tip",
|
||||
fingerprint=str(field.get("model_fingerprint") or "?")[:16],
|
||||
strategy=str(field.get("strategy") or "?"),
|
||||
reference=str(field.get("reference_id") or "?"),
|
||||
grid=grid,
|
||||
downsample="?" if downsample is None else str(downsample),
|
||||
)
|
||||
return note, tip
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplayActions:
|
||||
"""The five menu/shortcut entries whose meaning depends on the replay state."""
|
||||
@@ -1018,6 +1072,18 @@ class ReplayController(QObject):
|
||||
# A result just became readable, so it is what the operator is shown — unless a
|
||||
# derived view was already on screen (a depth view chosen while the pass ran).
|
||||
self.serve_stored_view()
|
||||
# A field-weighted result is an experiment; name its source and quality where
|
||||
# the operator reads the measurement. A scalar result answers ("", "") and
|
||||
# nothing is added.
|
||||
note, tip = field_experiment_note(session)
|
||||
if note:
|
||||
self._host.notify(note)
|
||||
try:
|
||||
self._charts.setToolTip(tip or note)
|
||||
except Exception: # noqa: BLE001 - a tooltip must not break adoption
|
||||
logger.debug(
|
||||
"could not attach the field experiment tooltip", exc_info=True
|
||||
)
|
||||
|
||||
def serve_stored_view(self) -> bool:
|
||||
"""Put the cached view on screen when a result becomes readable.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""S4: the replay names the field-weighted experiment a result came from.
|
||||
|
||||
The field model is an experiment, and its numbers are estimates: the result records
|
||||
its model fingerprint, the declared unloaded reference and the mean extrapolated
|
||||
fraction. The replay has to say so — and say "unknown" for a result that predates
|
||||
the diagnostics, never 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.analysis.model import FIELD_PROFILE
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
|
||||
from orisys_studio.replay_controller import field_experiment_note
|
||||
|
||||
|
||||
def _session(field: dict) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
reader=SimpleNamespace(provenance=SimpleNamespace(field=field))
|
||||
)
|
||||
|
||||
|
||||
FIELD = {
|
||||
"profile": FIELD_PROFILE,
|
||||
"strategy": "field-weighted-baseline-1",
|
||||
"model_fingerprint": "abcdef0123456789" * 4,
|
||||
"reference_id": "video-unloaded-36",
|
||||
"baseline_N": 0.25,
|
||||
"frames": 35,
|
||||
"extrapolated_mean": 0.3,
|
||||
"extrapolated_max": 0.4,
|
||||
"coverage_mean": 0.95,
|
||||
"hhd": {"downsample": 2, "grid_shape": [150, 140]},
|
||||
}
|
||||
|
||||
|
||||
def teardown_function():
|
||||
set_locale("zh_CN")
|
||||
|
||||
|
||||
def test_a_field_result_names_its_baseline_and_extrapolation():
|
||||
set_locale("zh_CN")
|
||||
note, tip = field_experiment_note(_session(dict(FIELD)))
|
||||
assert note == t(
|
||||
"chart.field_experiment",
|
||||
baseline="0.250",
|
||||
frames=35,
|
||||
extrapolated="30.0",
|
||||
)
|
||||
assert "field-weighted-baseline-1" in tip
|
||||
assert "abcdef0123456789" in tip
|
||||
assert "150x140" in tip or "150 × 140" in tip
|
||||
|
||||
set_locale("en_US")
|
||||
english, _ = field_experiment_note(_session(dict(FIELD)))
|
||||
assert english and english != note
|
||||
assert "30.0" in english
|
||||
|
||||
|
||||
def test_a_field_result_without_diagnostics_says_unknown():
|
||||
set_locale("zh_CN")
|
||||
note, tip = field_experiment_note(_session({"profile": FIELD_PROFILE}))
|
||||
assert note == t("chart.field_unknown")
|
||||
assert tip == t("chart.field_unknown_tip")
|
||||
|
||||
|
||||
def test_a_scalar_or_missing_result_has_no_field_note():
|
||||
assert field_experiment_note(_session({})) == ("", "")
|
||||
assert field_experiment_note(None) == ("", "")
|
||||
assert field_experiment_note(SimpleNamespace(reader=None)) == ("", "")
|
||||
@@ -171,3 +171,50 @@ def test_explicit_raw_choice_disables_default_calibration_before_processing(monk
|
||||
chain = ChainSpec(sensor_type=family, calibration_disabled=True)
|
||||
service_module._OpenChain(chain, video, tmp_path).open()
|
||||
assert applied == [None]
|
||||
|
||||
|
||||
def test_a_field_model_setup_gets_its_own_key_and_the_unloaded_window(
|
||||
monkeypatch, replay_recording, tmp_path
|
||||
):
|
||||
"""S4: the field model is a different experiment, so it is a different cache entry.
|
||||
|
||||
The strategy component is added by the analysis service from the chosen file's
|
||||
``model.input_profile``; the 30-frame declaration travels with the setup. A
|
||||
spatial result's key keeps its old components, so its existing cache still hits.
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
from orisys_app_shared.analysis import service as service_module
|
||||
|
||||
field_file = tmp_path / "field.json"
|
||||
field_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 3,
|
||||
"model": {
|
||||
"type": "field_gain/1",
|
||||
"input_profile": "hhd_normal_l1_field/1",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"calibration_profile",
|
||||
lambda path: ("field_gain/1", "hhd_normal_l1_field/1"),
|
||||
)
|
||||
monkeypatch.setattr(bare_video, "calibration_identity", lambda path: "field@abc")
|
||||
|
||||
service = AnalysisService(
|
||||
Path(replay_recording),
|
||||
chain=ChainSpec(sensor_type="finger", calibration=field_file),
|
||||
store_force=True,
|
||||
unloaded_reference_frames=30,
|
||||
)
|
||||
facts = service.dependency_report()
|
||||
assert facts["model_profile"] == "hhd_normal_l1_field/1"
|
||||
assert facts["key"].components["sensor_config"].endswith(";field-weighted=1")
|
||||
assert facts["key"].components["calibration"] == "field@abc"
|
||||
assert service.unloaded_reference_frames == 30
|
||||
# The recording itself was not touched by key computation.
|
||||
assert (Path(replay_recording) / "session.json").is_file()
|
||||
|
||||
Reference in New Issue
Block a user