c1e6d21030
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""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
|
|
)
|