Files
orisys-app/shared/tests/_field_fakes.py
T
ykcheungab c1e6d21030 将场加权标定模型接入回放分析
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。
新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题,
已归因并另立 studio-gate-hang。
2026-10-19 19:49:36 +08:00

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
)