新增 HHD 场加权标定模型与加权空载基线
field_gain/1 用坐标增益图对无旋向量场做 L1 场加权,以连续 30 帧加权中位数替代旧 raw 零点,实时入口暂不支持。目标测试 102 项与 lint_gate 通过;契约见 orisys-sdk/docs/field-gain-model.zh.md。
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
"""Field gain model: gain mapping, weighted reduction and unloaded baseline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.calibration import CalibrationError, parse, validate_for_device
|
||||
from orisys.calibration_context import build_context
|
||||
from orisys.calibration_models import parse_model
|
||||
from orisys.field_calibration import (
|
||||
BASELINE_POLICY,
|
||||
FIELD_IDENTITY,
|
||||
MAPPING_POLICY,
|
||||
build_field_gain_map,
|
||||
coerce_field,
|
||||
reduce_field,
|
||||
)
|
||||
|
||||
FULL_SQUARE = [[0.0, 0.0], [3.0, 0.0], [0.0, 3.0], [3.0, 3.0]]
|
||||
SMALL_SQUARE = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]]
|
||||
|
||||
|
||||
def _artifact(tmp_path, *, shape=(4, 4), uv=None, valid=None):
|
||||
height, width = shape
|
||||
if uv is None:
|
||||
rows, cols = np.mgrid[0:height, 0:width]
|
||||
uv = np.stack((cols, rows), axis=-1).astype(np.float64)
|
||||
if valid is None:
|
||||
valid = np.ones((height, width), dtype=bool)
|
||||
path = tmp_path / "map.npz"
|
||||
np.savez(path, uv_by_output_px=uv, valid_mask=valid, origin_xy=np.array([0, 0]))
|
||||
return path, hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _pipeline(*, digest):
|
||||
pipeline = {
|
||||
"stitch_resolution": [4, 4],
|
||||
"dis_mode": 1,
|
||||
"hhd_downsample": 1,
|
||||
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||
"source_scale": 0.5,
|
||||
"geometry_sha256": "b" * 64,
|
||||
"coordinates": {"frame": "autocal_flat", "unit": "px",
|
||||
"origin_xy": [0, 0], "map_sha256": digest},
|
||||
"feature_signature_sha256": "a" * 64,
|
||||
"field": deepcopy(FIELD_IDENTITY),
|
||||
}
|
||||
return pipeline
|
||||
|
||||
|
||||
def _document(tmp_path, *, gains=None, points=None, triangles=None, uv=None,
|
||||
coord_valid=None, artifact=True):
|
||||
height, width = (4, 4) if uv is None else uv.shape[:2]
|
||||
path, digest = _artifact(tmp_path, shape=(height, width), uv=uv, valid=coord_valid)
|
||||
pipeline = _pipeline(digest=digest)
|
||||
params = {
|
||||
"points_uv": points if points is not None else FULL_SQUARE,
|
||||
"gains_N_per_raw": gains if gains is not None else [1.0, 2.0, 3.0, 4.0],
|
||||
"triangles": triangles if triangles is not None else [[0, 1, 2], [1, 3, 2]],
|
||||
"valid_force_range_N": [0.0, 100.0],
|
||||
"mapping": deepcopy(MAPPING_POLICY),
|
||||
"baseline": deepcopy(BASELINE_POLICY),
|
||||
}
|
||||
document = {
|
||||
"schema_version": 3,
|
||||
"unit": "N",
|
||||
"identity": {"sensor_type": "finger", "sn": "DD02-GL"},
|
||||
"pipeline": pipeline,
|
||||
"model": {
|
||||
"type": "field_gain/1",
|
||||
"input_profile": "hhd_normal_l1_field/1",
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": params,
|
||||
},
|
||||
}
|
||||
if artifact:
|
||||
document["model"]["artifacts"] = [
|
||||
{"role": "coordinate_map", "path": path.name, "sha256": digest}
|
||||
]
|
||||
return document
|
||||
|
||||
|
||||
def _field(dx=0.5, dy=0.5, *, shape=(2, 2), area_scale=4.0, mask=None, dtype=np.float32):
|
||||
d = np.zeros((*shape, 2), dtype=dtype)
|
||||
d[:, :, 0] = dx
|
||||
d[:, :, 1] = dy
|
||||
if mask is None:
|
||||
mask = np.ones(shape, dtype=bool)
|
||||
mask = np.asarray(mask, dtype=bool)
|
||||
d[~mask] = 0.0
|
||||
return {"d": d, "valid_mask": mask, "area_scale": area_scale, "grid_shape": tuple(shape)}
|
||||
|
||||
|
||||
def _features(field, *, mask_shape=(4, 4), output_mask=None):
|
||||
if output_mask is None:
|
||||
output_mask = np.ones(mask_shape, dtype=bool)
|
||||
return {"field": field, "output_valid_mask": output_mask}
|
||||
|
||||
|
||||
def _model_with_gain(tmp_path, gain=1.0):
|
||||
document = _document(tmp_path, gains=[gain] * 4)
|
||||
return parse_model(document, tmp_path / "calibration.json")
|
||||
|
||||
|
||||
def _feed_ready(model, frames=30, field=None):
|
||||
model.begin_reference("unloaded")
|
||||
field = field if field is not None else _field()
|
||||
for _ in range(frames):
|
||||
model.predict(1, 0.0, _features(field))
|
||||
assert model.field_baseline_N is not None
|
||||
|
||||
|
||||
def test_parse_accepts_field_model_and_declares_profile(tmp_path):
|
||||
model = parse_model(_document(tmp_path), tmp_path / "calibration.json")
|
||||
assert model.model_type == "field_gain/1"
|
||||
assert model.input_profile == "hhd_normal_l1_field/1"
|
||||
assert model.channels == {"fn": "N"}
|
||||
assert model.to_dict()["model"]["params"]["mapping"] == MAPPING_POLICY
|
||||
assert len(model.fingerprint) == 64
|
||||
gain_map = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||
assert gain_map.gain.shape == (2, 2)
|
||||
assert np.allclose(gain_map.coverage, 1.0)
|
||||
|
||||
|
||||
def test_constant_gain_matches_hand_computed_weighted_sum(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
_feed_ready(model)
|
||||
result = model.predict(31, 0.0, _features(_field()))
|
||||
# q=1 on each of 4 cells, coverage 1, gain 1, A=4: P=16, baseline=16, FN=0.
|
||||
assert result.values_N["fn"] == pytest.approx(0.0)
|
||||
assert result.status["fn"] == "ok"
|
||||
assert result.diagnostics["baseline_N"] == pytest.approx(16.0)
|
||||
result = model.predict(32, 0.0, _features(_field(1.5, 1.5)))
|
||||
# q=3 per cell -> P=48, FN=48-16=32.
|
||||
assert result.values_N["fn"] == pytest.approx(32.0)
|
||||
|
||||
|
||||
def test_two_regions_contribute_and_vectors_combine_before_l1(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
_feed_ready(model)
|
||||
d = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
d[0, 0] = (1.0, 0.0)
|
||||
d[1, 1] = (0.0, 0.5)
|
||||
mask = np.ones((2, 2), dtype=bool)
|
||||
field = {"d": d, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||
result = model.predict(31, 0.0, _features(field))
|
||||
# q sums 1.5, two cells -> P=6; baseline 16 -> -10.
|
||||
assert result.values_N["fn"] == pytest.approx(-10.0)
|
||||
|
||||
combined = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
combined[0, 0] = (0.75, 0.0)
|
||||
# A negative and a positive vector at the same cell combine to 0.5, not 1.0.
|
||||
first = combined.copy()
|
||||
second = np.zeros_like(combined)
|
||||
second[0, 0] = (-0.25, 0.0)
|
||||
first_map = {"d": first, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||
second_map = {"d": second, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||
combined_map = {"d": first + second, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||
gain = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||
reduction = reduce_field(coerce_field(combined_map), gain)
|
||||
assert reduction.weighted_N == pytest.approx(4.0 * 0.5)
|
||||
assert reduction.weighted_N != pytest.approx(
|
||||
reduce_field(coerce_field(first_map), gain).weighted_N
|
||||
+ reduce_field(coerce_field(second_map), gain).weighted_N
|
||||
)
|
||||
|
||||
|
||||
def test_boundary_projection_and_mask_coverage(tmp_path):
|
||||
model = parse_model(_document(tmp_path, points=SMALL_SQUARE,
|
||||
triangles=[[0, 1, 2], [0, 2, 3]]),
|
||||
tmp_path / "calibration.json")
|
||||
gains = model.field_gain_map((4, 4), np.ones((4, 4), dtype=bool))
|
||||
# (1,1) is inside the small hull; (3,3) projects to the nearest edge.
|
||||
assert gains.gain[1, 1] == pytest.approx(2.0)
|
||||
assert gains.coverage[1, 1] == pytest.approx(1.0)
|
||||
assert gains.inside[1, 1] == pytest.approx(1.0)
|
||||
assert gains.gain[3, 3] == pytest.approx(3.0)
|
||||
assert gains.inside[3, 3] == pytest.approx(0.0)
|
||||
assert gains.coverage[3, 3] == pytest.approx(1.0)
|
||||
|
||||
# A physical mask that keeps only the left half removes the right cells entirely.
|
||||
constant = parse_model(_document(tmp_path, points=SMALL_SQUARE, gains=[1.0] * 4,
|
||||
triangles=[[0, 1, 2], [0, 2, 3]]),
|
||||
tmp_path / "calibration.json")
|
||||
left_half = np.zeros((4, 4), dtype=bool)
|
||||
left_half[:, :2] = True
|
||||
gains = constant.field_gain_map((2, 2), left_half)
|
||||
assert np.allclose(gains.coverage, [[1.0, 0.0], [1.0, 0.0]])
|
||||
field = _field(mask=np.array([[True, False], [True, False]]))
|
||||
reduction = reduce_field(coerce_field(field), gains)
|
||||
# Two active cells with q=1, A=4: P=8. Inactive cells never contribute.
|
||||
assert reduction.weighted_N == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_partial_coverage_constant_gain_still_matches_raw_sum(tmp_path):
|
||||
"""R1: coverage is diagnostic, not a second mask weight.
|
||||
|
||||
HHD raw counts a partially covered mask-edge cell in full; folding coverage
|
||||
into ``P`` would discount it again and break ``P == gain * raw``.
|
||||
"""
|
||||
model = _model_with_gain(tmp_path, gain=0.5)
|
||||
mask = np.ones((4, 4), dtype=bool)
|
||||
mask[0, 0] = False
|
||||
gain_map = model.field_gain_map((2, 2), mask)
|
||||
assert gain_map.coverage[0, 0] == pytest.approx(0.75)
|
||||
field = _field(0.5, 0.5, shape=(2, 2), area_scale=4.0)
|
||||
raw = 4.0 * float(np.sum(np.abs(field["d"])))
|
||||
reduction = reduce_field(coerce_field(field), gain_map)
|
||||
assert reduction.weighted_N == pytest.approx(0.5 * raw)
|
||||
assert reduction.coverage_fraction == pytest.approx((0.75 + 1.0 + 1.0 + 1.0) / 4.0)
|
||||
|
||||
|
||||
def test_active_hhd_cell_without_coverage_is_a_mapping_error(tmp_path):
|
||||
"""R1: an active cell with no mapped pixel must not be silently counted as zero."""
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
left_half = np.zeros((4, 4), dtype=bool)
|
||||
left_half[:, :2] = True
|
||||
gain_map = model.field_gain_map((2, 2), left_half)
|
||||
assert np.allclose(gain_map.coverage, [[1.0, 0.0], [1.0, 0.0]])
|
||||
with pytest.raises(ValueError, match="mapping error"):
|
||||
reduce_field(coerce_field(_field(mask=np.ones((2, 2), dtype=bool))), gain_map)
|
||||
result = model.predict(1, 0.0, _features(
|
||||
_field(mask=np.ones((2, 2), dtype=bool)), output_mask=left_half))
|
||||
assert result.status == {"fn": "coordinate_unmapped"}
|
||||
assert result.values_N == {"fn": None}
|
||||
|
||||
|
||||
def test_mapping_and_input_errors_are_explicit(tmp_path):
|
||||
uv = np.stack(np.mgrid[0:4, 0:4][::-1], axis=-1).astype(np.float64)
|
||||
valid = np.ones((4, 4), dtype=bool)
|
||||
valid[0, 0] = False
|
||||
model = parse_model(_document(tmp_path, uv=uv, coord_valid=valid),
|
||||
tmp_path / "calibration.json")
|
||||
with pytest.raises(ValueError, match="mapping error"):
|
||||
model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||
result = model.predict(1, 0.0, _features(_field()))
|
||||
assert result.status == {"fn": "coordinate_unmapped"}
|
||||
assert result.values_N == {"fn": None}
|
||||
|
||||
broken = _field()
|
||||
broken["d"][0, 0, 0] = np.nan
|
||||
result = model.predict(2, 0.0, _features(broken))
|
||||
assert result.status == {"fn": "invalid_input"}
|
||||
assert result.values_N == {"fn": None}
|
||||
|
||||
wrong_dtype = _field(dtype=np.float64)
|
||||
with pytest.raises(ValueError, match="dtype"):
|
||||
coerce_field(wrong_dtype)
|
||||
wrong_shape = _field()
|
||||
wrong_shape["grid_shape"] = (3, 2)
|
||||
with pytest.raises(ValueError, match="grid_shape"):
|
||||
coerce_field(wrong_shape)
|
||||
|
||||
|
||||
def test_empty_field_reports_zero_and_negative_baseline_difference(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
_feed_ready(model)
|
||||
empty = _field(0.0, 0.0)
|
||||
result = model.predict(31, 0.0, _features(empty))
|
||||
assert result.status["fn"] == "out_of_range"
|
||||
assert result.values_N["fn"] == pytest.approx(-16.0)
|
||||
assert result.diagnostics["extrapolated_fraction"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_weighted_baseline_needs_declaration_and_thirty_consecutive_frames(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
result = model.predict(1, 0.0, _features(_field()))
|
||||
assert result.status == {"fn": "zero_unavailable"}
|
||||
assert result.diagnostics["pending_reference_frames"] == 0
|
||||
model.begin_reference("unloaded")
|
||||
for _ in range(29):
|
||||
result = model.predict(1, 0.0, _features(_field()))
|
||||
assert result.status == {"fn": "zero_unavailable"}
|
||||
result = model.predict(1, 0.0, _features(_field()))
|
||||
assert result.status == {"fn": "zero_unavailable"}
|
||||
assert result.diagnostics["baseline_N"] == pytest.approx(16.0)
|
||||
assert result.diagnostics["reference_id"] == "unloaded"
|
||||
ready = model.predict(2, 0.0, _features(_field()))
|
||||
assert ready.status["fn"] == "ok"
|
||||
|
||||
model.begin_reference("second")
|
||||
invalid = _field()
|
||||
invalid["d"][0, 0, 0] = np.nan
|
||||
for _ in range(29):
|
||||
model.predict(3, 0.0, _features(_field()))
|
||||
assert model.predict(3, 0.0, _features(invalid)).status == {"fn": "invalid_input"}
|
||||
result = model.predict(4, 0.0, _features(_field()))
|
||||
assert result.diagnostics["pending_reference_frames"] == 1
|
||||
assert result.status == {"fn": "zero_unavailable"}
|
||||
|
||||
|
||||
def test_reset_and_model_identity_void_the_weighted_baseline(tmp_path):
|
||||
document = _document(tmp_path, gains=[1.0] * 4)
|
||||
model = parse_model(document, tmp_path / "calibration.json")
|
||||
_feed_ready(model)
|
||||
assert model.field_baseline_N is not None
|
||||
model.reset(None)
|
||||
assert model.field_baseline_N is None
|
||||
result = model.predict(31, 0.0, _features(_field()))
|
||||
assert result.status == {"fn": "zero_unavailable"}
|
||||
fresh = parse_model(document, tmp_path / "calibration.json")
|
||||
assert fresh.field_baseline_N is None
|
||||
assert fresh.predict(31, 0.0, _features(_field())).status == {"fn": "zero_unavailable"}
|
||||
|
||||
|
||||
def test_field_document_rejections(tmp_path):
|
||||
mutations = [
|
||||
lambda data: data["model"].update(input_profile="fn_delta_autocal_uv/1"),
|
||||
lambda data: data["model"].update(outputs=["fn", "fx"]),
|
||||
lambda data: data["model"].pop("artifacts"),
|
||||
lambda data: data["model"]["params"].update(mapping={"inside": "nearest"}),
|
||||
lambda data: data["model"]["params"].update(baseline={"frames": 29, "statistic": "median"}),
|
||||
lambda data: data["pipeline"].pop("field"),
|
||||
lambda data: data["pipeline"].update(field={"grid": "other"}),
|
||||
lambda data: data["model"]["params"].update(extra=True),
|
||||
lambda data: data["model"]["params"].update(gains_N_per_raw=[1.0, 2.0, 3.0, 0.0]),
|
||||
lambda data: data["pipeline"].pop("geometry_sha256"),
|
||||
]
|
||||
for mutate in mutations:
|
||||
data = _document(tmp_path)
|
||||
mutate(data)
|
||||
with pytest.raises(ValueError):
|
||||
parse_model(data, tmp_path / "calibration.json")
|
||||
|
||||
|
||||
def test_field_model_is_loadable_through_calibration_entry_points(tmp_path):
|
||||
document = _document(tmp_path)
|
||||
model = parse(document, source_path=tmp_path / "calibration.json")
|
||||
assert model.schema_version == 3
|
||||
assert parse_model(document, tmp_path / "calibration.json").model_type == "field_gain/1"
|
||||
with pytest.raises(CalibrationError, match="identity mismatch"):
|
||||
validate_for_device(model, "finger", "OTHER-SN")
|
||||
|
||||
|
||||
def test_context_includes_field_identity_and_rejects_scalar_profile(tmp_path):
|
||||
path, digest = _artifact(tmp_path)
|
||||
coordinates = {"frame": "autocal_flat", "unit": "px",
|
||||
"origin_xy": [0, 0], "map_sha256": digest}
|
||||
config = {
|
||||
"type": "compound_eye",
|
||||
"camera": {"resolution_raw": [4, 4], "resolution_stitch": [4, 4]},
|
||||
"tracker": {"dis_mode": 1, "hhd_downsample": 1},
|
||||
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||
"contact": {},
|
||||
}
|
||||
context = build_context(config, config["filters"], geometry_sha256="b" * 64,
|
||||
source_scale=0.5, coordinates=coordinates,
|
||||
processing_backend="uv_unroll",
|
||||
preprocessing={"flip_source_vertical": True, "mirror": False},
|
||||
field=FIELD_IDENTITY)
|
||||
document = _document(tmp_path)
|
||||
document["pipeline"] = dict(context)
|
||||
model = parse_model(document, tmp_path / "calibration.json")
|
||||
model.validate_for_context(context)
|
||||
scalar_context = build_context(config, config["filters"], geometry_sha256="b" * 64,
|
||||
source_scale=0.5, coordinates=coordinates,
|
||||
processing_backend="uv_unroll")
|
||||
with pytest.raises(ValueError, match="field"):
|
||||
model.validate_for_context(scalar_context)
|
||||
|
||||
|
||||
def test_gain_map_is_deterministic_across_builds(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=2.0)
|
||||
first = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||
model._field_maps.clear()
|
||||
second = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||
assert np.array_equal(first.gain, second.gain)
|
||||
assert np.array_equal(first.coverage, second.coverage)
|
||||
assert first.physical_mask_sha256 == second.physical_mask_sha256
|
||||
with pytest.raises(ValueError, match="mask"):
|
||||
build_field_gain_map(model._points, model._gains, model._triangles,
|
||||
model.coordinate_map, model.coordinate_valid_mask,
|
||||
(2, 2), np.ones((3, 3), dtype=bool))
|
||||
|
||||
|
||||
def test_field_model_ignores_contact_state(tmp_path):
|
||||
model = _model_with_gain(tmp_path, gain=1.0)
|
||||
model.begin_reference("unloaded")
|
||||
for _ in range(30):
|
||||
model.predict(1, 0.0, _features(_field()))
|
||||
features = _features(_field())
|
||||
features.update(contact_found=False, zero_valid=False)
|
||||
result = model.predict(31, 0.0, features)
|
||||
assert result.status["fn"] == "ok"
|
||||
assert result.values_N["fn"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def _hhd(grid_shape, reference_shape, downsample):
|
||||
from orisys.hhd import nHHD
|
||||
|
||||
return nHHD(cuda=False, grid=grid_shape, spacings=(18 * downsample, 18 * downsample),
|
||||
poisson_backend="cpu", force_reference_grid=reference_shape)
|
||||
|
||||
|
||||
def _identity_uv(shape):
|
||||
rows, cols = np.mgrid[0:shape[0], 0:shape[1]]
|
||||
return np.stack((cols, rows), axis=-1).astype(np.float64)
|
||||
|
||||
|
||||
def test_hhd_field_payload_uses_actual_reference_area_scale():
|
||||
reference = (9, 8)
|
||||
for downsample in (1, 2, 3, 4):
|
||||
grid = (reference[0] // downsample, reference[1] // downsample)
|
||||
solver = _hhd(grid, reference, downsample)
|
||||
solver.get_force(np.zeros((*grid, 2), dtype=np.float32))
|
||||
payload = solver.get_field()
|
||||
assert payload["area_scale"] == pytest.approx(
|
||||
reference[0] * reference[1] / (grid[0] * grid[1]))
|
||||
assert payload["grid_shape"] == grid
|
||||
assert payload["reference_shape"] == reference
|
||||
assert payload["d"].dtype == np.float32
|
||||
assert payload["d"].shape == (*grid, 2)
|
||||
payload["d"][0, 0, 0] = 123.0
|
||||
assert solver.d[0, 0, 0] != 123.0
|
||||
|
||||
|
||||
def test_constant_gain_matches_raw_hhd_sum(tmp_path):
|
||||
shape = (6, 5)
|
||||
rng = np.random.default_rng(7)
|
||||
solver = _hhd(shape, shape, 1)
|
||||
_, forces = solver.get_force(rng.normal(size=(*shape, 2)).astype(np.float32))
|
||||
raw_sn = float(forces[0])
|
||||
payload = solver.get_field()
|
||||
uv = _identity_uv(shape)
|
||||
document = _document(tmp_path, gains=[0.5] * 4, uv=uv,
|
||||
points=[[0.0, 0.0], [shape[1] - 1.0, 0.0],
|
||||
[0.0, shape[0] - 1.0], [shape[1] - 1.0, shape[0] - 1.0]],
|
||||
triangles=[[0, 1, 2], [1, 3, 2]])
|
||||
model = parse_model(document, tmp_path / "calibration.json")
|
||||
gain_map = model.field_gain_map(shape, np.ones(shape, dtype=bool))
|
||||
assert np.allclose(gain_map.coverage, 1.0)
|
||||
reduction = reduce_field(coerce_field(payload), gain_map)
|
||||
# Constant gain: the field-weighted sum equals the raw HHD sum times the gain.
|
||||
assert reduction.weighted_N == pytest.approx(0.5 * raw_sn, rel=1e-5, abs=1e-6)
|
||||
|
||||
|
||||
def test_constant_field_integral_across_downsample_and_nondivisible_grids(tmp_path):
|
||||
reference = (9, 8)
|
||||
expected = 0.5 * 2.0 * reference[0] * reference[1]
|
||||
for downsample in (1, 2, 3, 4):
|
||||
grid = (reference[0] // downsample, reference[1] // downsample)
|
||||
solver = _hhd(grid, reference, downsample)
|
||||
solver.get_force(np.zeros((*grid, 2), dtype=np.float32))
|
||||
payload = solver.get_field()
|
||||
payload["d"] = np.ones((*grid, 2), dtype=np.float32)
|
||||
payload["valid_mask"] = np.ones(grid, dtype=bool)
|
||||
uv = _identity_uv(reference)
|
||||
document = _document(
|
||||
tmp_path, gains=[0.5] * 4, uv=uv,
|
||||
points=[[0.0, 0.0], [reference[1] - 1.0, 0.0],
|
||||
[0.0, reference[0] - 1.0], [reference[1] - 1.0, reference[0] - 1.0]],
|
||||
triangles=[[0, 1, 2], [1, 3, 2]],
|
||||
)
|
||||
model = parse_model(document, tmp_path / "calibration.json")
|
||||
gain_map = model.field_gain_map(grid, np.ones(reference, dtype=bool))
|
||||
reduction = reduce_field(coerce_field(payload), gain_map)
|
||||
# q=2 per cell; A*cell_count == reference pixel count for every integer
|
||||
# downsample, including grids that do not divide the reference exactly.
|
||||
assert reduction.weighted_N == pytest.approx(expected, rel=1e-5, abs=1e-6)
|
||||
@@ -345,3 +345,113 @@ def test_bad_zero_reference_is_rejected_without_reset(reference):
|
||||
with pytest.raises(ValueError):
|
||||
sensor.set_calibration_reference(reference, reference_id="bad")
|
||||
assert sensor._calibration_reference == {"fn": 10.}
|
||||
|
||||
|
||||
class FieldRecordingModel:
|
||||
schema_version = 3
|
||||
channels = {"fn": "N"}
|
||||
input_profile = "hhd_normal_l1_field/1"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.resets = []
|
||||
self.begin_calls = []
|
||||
self.coordinate_map = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
self.coordinate_valid_mask = np.ones((2, 2), dtype=bool)
|
||||
|
||||
def reset(self, reference_id):
|
||||
self.resets.append(reference_id)
|
||||
|
||||
def begin_reference(self, reference_id):
|
||||
self.begin_calls.append(reference_id)
|
||||
|
||||
def validate_for_context(self, context):
|
||||
if context != {"signature": "actual"}:
|
||||
raise CalibrationError("pipeline mismatch")
|
||||
|
||||
def predict(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
features = kwargs["features"]
|
||||
if "field" not in features:
|
||||
return SimpleNamespace(values_N={"fn": None}, status={"fn": "invalid_input"},
|
||||
diagnostics={})
|
||||
return SimpleNamespace(values_N={"fn": 1.0}, status={"fn": "ok"}, diagnostics={})
|
||||
|
||||
|
||||
def test_hhd_field_model_infers_from_same_frame_field_without_contact():
|
||||
sensor = make_sensor()
|
||||
sensor.calibration = FieldRecordingModel()
|
||||
sensor._calibration_context = {"signature": "actual"}
|
||||
sensor.cuda = sensor.verbose = False
|
||||
sensor.valid_mask = sensor.valid_mask_hhd = None
|
||||
sensor._hhd_downsample = 1
|
||||
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
|
||||
sensor._model_frame_id = 0
|
||||
field = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
calls = []
|
||||
|
||||
def get_field(valid_mask=None):
|
||||
calls.append(valid_mask)
|
||||
return {"d": field, "valid_mask": np.ones((2, 2), dtype=bool), "area_scale": 1.0,
|
||||
"grid_shape": (2, 2), "reference_shape": (2, 2)}
|
||||
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
|
||||
get_normal=lambda: field, get_shear=lambda: field, get_field=get_field,
|
||||
)
|
||||
sensor.compute_hhd(np.zeros((2, 2, 2), dtype=np.float32))
|
||||
features = sensor.calibration.calls[-1]["features"]
|
||||
assert features["field"]["grid_shape"] == (2, 2)
|
||||
assert features["output_valid_mask"].shape == (2, 2)
|
||||
assert "contact_found" not in features
|
||||
assert sensor.calibration_status == {"fn": "ok"}
|
||||
assert sensor.fnormal == pytest.approx(1.0)
|
||||
assert calls == [None]
|
||||
|
||||
|
||||
def test_weighted_unloaded_reference_is_declared_not_a_scalar_zero():
|
||||
sensor = make_sensor()
|
||||
sensor.calibration = FieldRecordingModel()
|
||||
sensor._calibration_context = {"signature": "actual"}
|
||||
sensor.declare_unloaded_reference(reference_id="video-unloaded-100")
|
||||
assert sensor.calibration.begin_calls == ["video-unloaded-100"]
|
||||
assert sensor._calibration_reference_id == "video-unloaded-100"
|
||||
with pytest.raises(CalibrationError, match="scalar zero"):
|
||||
sensor.set_calibration_reference({"fn": 0.}, reference_id="bad")
|
||||
with pytest.raises(CalibrationError, match="hhd_normal_l1_field"):
|
||||
make_sensor().declare_unloaded_reference(reference_id="scalar-model")
|
||||
|
||||
|
||||
def test_finger_runtime_binds_field_identity_and_forwards_the_window(monkeypatch, tmp_path):
|
||||
import orisys.calibration_context as context_mod
|
||||
from orisys.field_calibration import FIELD_IDENTITY
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
sensor = make_sensor()
|
||||
sensor.calibration = FieldRecordingModel()
|
||||
sensor._calibration_context = {"signature": "actual"}
|
||||
sensor.device_id = None
|
||||
sensor.mirror = False
|
||||
runtime = FingerRuntime.__new__(FingerRuntime)
|
||||
runtime.tactile = SimpleNamespace(raw_sensor=sensor)
|
||||
mesh = tmp_path / "mesh.npz"
|
||||
mesh.write_bytes(b"actual geometry")
|
||||
runtime.geometry = SimpleNamespace(config={}, export_paths=SimpleNamespace(mesh=mesh),
|
||||
source_scale=.42, out_height=2, out_width=2)
|
||||
runtime.domain = object()
|
||||
runtime.frame_processor = SimpleNamespace(flip_source_vertical=True)
|
||||
monkeypatch.setattr(context_mod, "domain_fingerprint", lambda domain: "domain")
|
||||
contexts = []
|
||||
|
||||
def context(config, filters, **kwargs):
|
||||
contexts.append(kwargs)
|
||||
return {"signature": "actual"}
|
||||
|
||||
monkeypatch.setattr(context_mod, "build_context", context)
|
||||
selected = FieldRecordingModel()
|
||||
selected.pipeline = {"coordinates": {"frame": "autocal_flat"}}
|
||||
runtime.set_calibration(selected)
|
||||
assert contexts[0]["field"] == FIELD_IDENTITY
|
||||
runtime.declare_unloaded_reference("runtime-window")
|
||||
assert sensor.calibration.begin_calls == ["runtime-window"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user