将场加权标定模型接入回放分析
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"""Fake SDK surface for the S4 field-model tests (shared by two test modules).
|
||||
|
||||
The real SDK field model is exercised by ``orisys-sdk/tests/test_field_calibration.py``;
|
||||
the app tests describe the *calls and data* the app owns. Keeping the fakes in one
|
||||
place means the pass test and the integration test cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition import sdk_worker
|
||||
from orisys_app_shared.analysis import service as service_module
|
||||
from orisys_app_shared.analysis.model import Provenance
|
||||
from orisys_app_shared.record.playback import RecordingFrame
|
||||
|
||||
FIELD_PROFILE = "hhd_normal_l1_field/1"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ calibration
|
||||
def write_model_file(tmp_path: Path, model_type: str) -> Path:
|
||||
"""A schema 3 calibration document the SDK accepts, with its coordinate map.
|
||||
|
||||
``field_gain/1`` and ``spatial_gain/1`` differ only in the model block, so a test
|
||||
can compare two documents that really load through the SDK's parser.
|
||||
"""
|
||||
from orisys.field_calibration import BASELINE_POLICY, FIELD_IDENTITY, MAPPING_POLICY
|
||||
|
||||
rows, cols = np.mgrid[0:4, 0:4]
|
||||
uv = np.stack((cols, rows), axis=-1).astype(np.float64)
|
||||
attachment = tmp_path / "map.npz"
|
||||
np.savez(
|
||||
attachment,
|
||||
uv_by_output_px=uv,
|
||||
valid_mask=np.ones((4, 4), dtype=bool),
|
||||
origin_xy=np.array([0, 0]),
|
||||
)
|
||||
digest = hashlib.sha256(attachment.read_bytes()).hexdigest()
|
||||
pipeline = {
|
||||
"stitch_resolution": [4, 4],
|
||||
"dis_mode": 1,
|
||||
"hhd_downsample": 1,
|
||||
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||
"feature_signature_sha256": "a" * 64,
|
||||
"geometry_sha256": "b" * 64,
|
||||
"source_scale": 0.42,
|
||||
"coordinates": {
|
||||
"frame": "autocal_flat",
|
||||
"unit": "px",
|
||||
"origin_xy": [0, 0],
|
||||
"map_sha256": digest,
|
||||
},
|
||||
}
|
||||
params = {
|
||||
"points_uv": [[0, 0], [3, 0], [0, 3], [3, 3]],
|
||||
"gains_N_per_raw": [1.0, 1.0, 1.0, 1.0],
|
||||
"triangles": [[0, 1, 2], [1, 3, 2]],
|
||||
"valid_force_range_N": [0.0, 3.0],
|
||||
}
|
||||
if model_type == "field_gain/1":
|
||||
pipeline["field"] = dict(FIELD_IDENTITY)
|
||||
profile = FIELD_PROFILE
|
||||
params["mapping"] = dict(MAPPING_POLICY)
|
||||
params["baseline"] = dict(BASELINE_POLICY)
|
||||
else:
|
||||
profile = "fn_delta_autocal_uv/1"
|
||||
document = {
|
||||
"schema_version": 3,
|
||||
"unit": "N",
|
||||
"identity": {"sensor_type": "finger", "sn": "dd02-gl"},
|
||||
"pipeline": pipeline,
|
||||
"model": {
|
||||
"type": model_type,
|
||||
"input_profile": profile,
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": params,
|
||||
"artifacts": [
|
||||
{"role": "coordinate_map", "path": attachment.name, "sha256": digest}
|
||||
],
|
||||
},
|
||||
}
|
||||
path = tmp_path / ("field.json" if model_type == "field_gain/1" else "spatial.json")
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- recording
|
||||
def minimal_recording(root: Path, *, segments: int = 1) -> Path:
|
||||
"""The two text files and the segment bytes a reader needs, and nothing else."""
|
||||
recording = root / "recording"
|
||||
recording.mkdir(parents=True, exist_ok=True)
|
||||
(recording / "frames.csv").write_text(
|
||||
"frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped\n"
|
||||
"1,0.0,,1,1,1,0\n2,0.033,,1,2,1,0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for number in range(1, segments + 1):
|
||||
(recording / f"raw_{number:04d}.mp4").write_bytes(b"")
|
||||
(recording / "session.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": 1,
|
||||
"started_at": "2026-01-01T00:00:00+0000",
|
||||
"ended_at": "",
|
||||
"device": {"sensor_type": "finger", "config_ref": ""},
|
||||
"stats": {"frames": 2, "video_frames": 2, "segments": segments},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return recording
|
||||
|
||||
|
||||
def field_rows(count: int, *, segment: int = 1) -> list[RecordingFrame]:
|
||||
return [
|
||||
RecordingFrame(i + 1, i / 30.0, "", segment, i + 1, True)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- SDK
|
||||
class FakeFieldModel:
|
||||
schema_version = 3
|
||||
input_profile = FIELD_PROFILE
|
||||
model_type = "field_gain/1"
|
||||
channels = {"fn": "N"}
|
||||
fingerprint = "abcdef0123456789" * 4
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.field_baseline_N: float | None = None
|
||||
|
||||
|
||||
class FakeFieldSensor:
|
||||
"""The slice of ``Sensor`` the pass touches, simulating the SDK's window."""
|
||||
|
||||
def __init__(self, *, zero_frames: int = 30) -> None:
|
||||
self.info = SimpleNamespace(
|
||||
FNORMAL=0,
|
||||
FSHEARX=1,
|
||||
FSHEARY=2,
|
||||
FNORMAL_RAW=3,
|
||||
FSHEARX_RAW=4,
|
||||
FSHEARY_RAW=5,
|
||||
)
|
||||
self.calibration = FakeFieldModel()
|
||||
self.force_calibrated = True
|
||||
self.centroid_found = True # the field model must not gate on it
|
||||
self.flow = np.zeros((4, 4, 2), dtype=np.float32)
|
||||
self.valid_mask = None
|
||||
self.calibration_status = {"fn": "zero_unavailable"}
|
||||
self.calibration_result = None
|
||||
self.declared: list[str] = []
|
||||
self.set_reference_calls: list[tuple] = []
|
||||
self.pending = 0
|
||||
self.frame_index = 0
|
||||
self.fn_value = float("nan")
|
||||
self.zero_frames = zero_frames
|
||||
self._hhd_downsample = 2
|
||||
self._hhd_grid_shape = (2, 2)
|
||||
self._hhd_ref_shape = (4, 4)
|
||||
|
||||
def declare_unloaded_reference(self, *, reference_id: str) -> None:
|
||||
self.declared.append(str(reference_id))
|
||||
self.pending = 0
|
||||
self.calibration.field_baseline_N = None
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id) -> None: # pragma: no cover
|
||||
self.set_reference_calls.append((raw, reference_id))
|
||||
|
||||
def read_info(self, *keys):
|
||||
values = {
|
||||
0: self.fn_value,
|
||||
1: float("nan"),
|
||||
2: float("nan"),
|
||||
3: 100.0,
|
||||
4: 2.0,
|
||||
5: 3.0,
|
||||
}
|
||||
return tuple(values[key] for key in keys)
|
||||
|
||||
def step(self):
|
||||
"""One frame of the SDK's field model: 30-frame window, then ok frames."""
|
||||
self.frame_index += 1
|
||||
diagnostics: dict = {}
|
||||
if self.declared:
|
||||
if self.pending < self.zero_frames:
|
||||
self.pending += 1
|
||||
diagnostics = {
|
||||
"pending_reference_frames": self.pending,
|
||||
"extrapolated_fraction": 0.4,
|
||||
"coverage_fraction": 0.9,
|
||||
"model_fingerprint": self.calibration.fingerprint,
|
||||
"frame_id": self.frame_index,
|
||||
}
|
||||
if self.pending == self.zero_frames:
|
||||
self.calibration.field_baseline_N = 0.25
|
||||
diagnostics["baseline_N"] = 0.25
|
||||
diagnostics["pending_reference_frames"] = 0
|
||||
diagnostics["extrapolated_fraction"] = 0.3
|
||||
self.calibration_status = {"fn": "zero_unavailable"}
|
||||
self.fn_value = float("nan")
|
||||
else:
|
||||
diagnostics = {
|
||||
"baseline_N": 0.25,
|
||||
"extrapolated_fraction": 0.2,
|
||||
"coverage_fraction": 0.97,
|
||||
"model_fingerprint": self.calibration.fingerprint,
|
||||
}
|
||||
self.calibration_status = {"fn": "ok"}
|
||||
self.fn_value = 1.5
|
||||
else:
|
||||
diagnostics = {"model_fingerprint": self.calibration.fingerprint}
|
||||
self.calibration_result = SimpleNamespace(
|
||||
diagnostics=diagnostics,
|
||||
status=dict(self.calibration_status),
|
||||
values_N={"fn": None if math.isnan(self.fn_value) else self.fn_value},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class FakeChain:
|
||||
def __init__(self, sensor, *, frames: int = 35) -> None:
|
||||
self.sensor = sensor
|
||||
self.frames = int(frames)
|
||||
self.index = 0
|
||||
self.open_seconds = 0.0
|
||||
self.debug_files: list[str] = []
|
||||
self.decode_position = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
if self.index >= self.frames:
|
||||
return False, None
|
||||
self.index += 1
|
||||
return True, np.zeros((4, 4), dtype=np.uint8)
|
||||
|
||||
def feed(self, frame):
|
||||
return frame
|
||||
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self, rows, *, segments: int = 1, base: Path) -> None:
|
||||
self._rows = list(rows)
|
||||
self._segments = [
|
||||
base / f"raw_{number:04d}.mp4" for number in range(1, segments + 1)
|
||||
]
|
||||
|
||||
def session(self):
|
||||
return {
|
||||
"session_id": 1,
|
||||
"started_at": "2026-01-01T00:00:00+0000",
|
||||
"ended_at": "",
|
||||
"device": {"sensor_type": "finger", "config_ref": ""},
|
||||
"stats": {"frames": len(self._rows), "segments": len(self._segments)},
|
||||
}
|
||||
|
||||
def info(self):
|
||||
return SimpleNamespace(frames=self._rows, video_frames=len(self._rows))
|
||||
|
||||
def segments(self):
|
||||
return list(self._segments)
|
||||
|
||||
def segment_path(self, number: int):
|
||||
index = int(number) - 1
|
||||
return self._segments[index] if 0 <= index < len(self._segments) else None
|
||||
|
||||
|
||||
def install_pass(monkeypatch, processor, chain: FakeChain, reader: FakeReader) -> None:
|
||||
"""Make the service walk the fake chain instead of opening the SDK."""
|
||||
monkeypatch.setattr(processor, "reader", lambda: reader)
|
||||
monkeypatch.setattr(processor, "_segment_offset", lambda *args: (0, True))
|
||||
monkeypatch.setattr(service_module, "_OpenChain", lambda *args: chain)
|
||||
monkeypatch.setattr(sdk_worker, "process_chain", lambda sensor: (sensor.step(), ""))
|
||||
|
||||
|
||||
def new_pass_state(processor, tmp_path: Path, *, count: int = 35):
|
||||
rows = field_rows(count)
|
||||
return service_module._PassState(
|
||||
Provenance(), [1], {1: list(rows)}, tmp_path
|
||||
)
|
||||
Reference in New Issue
Block a user