e3b60f3d6d
光流、HHD 和场从 Sensor 中抽到 ImageSettings/ImagePipeline,Sensor 与 FingerRuntime 改为委托,公开接口与数值不变(222 个基线数组逐位一致,含运行时 flow_ema_alpha 场景)。参考版本只有一个来源:StreamPipeline 在 flow 线程领取的版本逐帧交给图像块。对外数组为只读副本;输出带 stream-contract 的 MeasurementFrame 头。回归 357 项、lint 通过。
522 lines
19 KiB
Python
522 lines
19 KiB
Python
"""ImagePipeline (T05): independent image block, delegation, read-only results.
|
|
|
|
The image block is verified three ways: the same prepared frames through the
|
|
published Sensor path and through a standalone ImagePipeline must produce
|
|
bit-identical flow/HHD/force/field results; measured arrays must be read-only
|
|
copies that later frames cannot overwrite; and the optical-flow reference
|
|
generation must be written when the image thread computes a frame (a reset
|
|
requested while a frame is in flight applies to the next frame only).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys import Sensor, configs
|
|
from orisys.image_pipeline import (
|
|
ImageMeasurement,
|
|
ImagePipeline,
|
|
ImageSettings,
|
|
sensor_clock,
|
|
)
|
|
from orisys.sensors import build_sensor_context
|
|
from orisys.stream_contract import FrameIdentity, MeasurementFrame
|
|
|
|
|
|
def _frames(count: int = 4, size: int = 365) -> list[np.ndarray]:
|
|
"""Deterministic grey frames whose pattern translates a few pixels."""
|
|
height = width = size
|
|
yy, xx = np.mgrid[0:height, 0:width]
|
|
frames = []
|
|
for index in range(count):
|
|
cy = 130.0 + 2.0 * index
|
|
cx = 210.0 + 3.0 * index
|
|
blob = 120.0 * np.exp(-(((xx - cx) / 25.0) ** 2 + ((yy - cy) / 21.0) ** 2))
|
|
gradient = 40.0 + 30.0 * (xx / width) + 20.0 * (yy / height)
|
|
frames.append(np.clip(gradient + blob, 0.0, 255.0).astype(np.uint8))
|
|
return frames
|
|
|
|
|
|
def _config(name: str = "box") -> dict:
|
|
return configs.load_config(name)
|
|
|
|
|
|
def _context():
|
|
cfg = _config()
|
|
return build_sensor_context(
|
|
config_name="box",
|
|
config=cfg,
|
|
device_id="BOX-001",
|
|
bundle_candidates=["box"],
|
|
)
|
|
|
|
|
|
def _identity(context, frame_id: int = 0) -> FrameIdentity:
|
|
return context.frame_identity(
|
|
session_id="run-1", frame_id=frame_id, source_frame_id=frame_id
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------- same-run parity
|
|
def test_standalone_pipeline_matches_the_published_sensor_path() -> None:
|
|
"""Old (Sensor delegation) and new (standalone) paths, same frames, same run."""
|
|
sensor = Sensor(
|
|
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
|
)
|
|
try:
|
|
settings = ImageSettings.from_config(
|
|
_config(),
|
|
cuda=False,
|
|
backend="cpu",
|
|
flow_ema_alpha=sensor._force_readout.flow_ema_alpha,
|
|
)
|
|
assert settings.as_dict() == sensor.image_settings.as_dict()
|
|
pipeline = ImagePipeline(settings)
|
|
context = _context()
|
|
|
|
for index, raw in enumerate(_frames()):
|
|
prepared = sensor.get_img(
|
|
frame=raw, already_processed=True, already_gray=True
|
|
)
|
|
assert np.array_equal(prepared, raw), "same-size resize must be identity"
|
|
|
|
sensor.compute_flow()
|
|
sensor.compute_hhd()
|
|
sensor_flow = np.array(sensor.flow, copy=True)
|
|
sensor_vnormal = np.array(sensor.vnormal, copy=True)
|
|
sensor_vshear = np.array(sensor.vshear, copy=True)
|
|
sensor_field = sensor.current_field()
|
|
sensor_force = tuple(
|
|
sensor.read_info(
|
|
sensor.info.FNORMAL_RAW,
|
|
sensor.info.FSHEARX_RAW,
|
|
sensor.info.FSHEARY_RAW,
|
|
)
|
|
)
|
|
|
|
measurement = pipeline.measure(
|
|
raw,
|
|
identity=_identity(context, index),
|
|
clock=sensor_clock(1_000_000 + index, clock_id="host"),
|
|
include=("flow", "vnormal", "vshear", "field", "valid_mask"),
|
|
)
|
|
assert np.array_equal(measurement.flow, sensor_flow), index
|
|
assert np.array_equal(measurement.vnormal, sensor_vnormal), index
|
|
assert np.array_equal(measurement.vshear, sensor_vshear), index
|
|
assert measurement.force_raw == pytest.approx(sensor_force), index
|
|
for key, value in sensor_field.items():
|
|
if isinstance(value, np.ndarray):
|
|
assert np.array_equal(measurement.field[key], value), (index, key)
|
|
else:
|
|
assert measurement.field[key] == value, (index, key)
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
def test_standalone_pipeline_runs_without_constructing_a_sensor() -> None:
|
|
context = _context()
|
|
pipeline = ImagePipeline(
|
|
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
|
)
|
|
measurement = pipeline.measure(
|
|
_frames()[0],
|
|
identity=_identity(context),
|
|
clock=sensor_clock(123, clock_id="host"),
|
|
source_dt_s=None,
|
|
include=("flow",),
|
|
)
|
|
assert isinstance(measurement, ImageMeasurement)
|
|
assert isinstance(measurement.frame, MeasurementFrame)
|
|
assert measurement.frame.stamp.sensor_id == "BOX-001"
|
|
assert measurement.frame.stamp.session_id == "run-1"
|
|
assert measurement.frame.reference_generation == 0
|
|
assert measurement.frame.source_dt_s is None
|
|
# The T03 header is the serialised contract: it round-trips unchanged.
|
|
assert (
|
|
MeasurementFrame.from_dict(json.loads(json.dumps(measurement.frame.to_dict())))
|
|
== measurement.frame
|
|
)
|
|
assert pipeline.context is context
|
|
|
|
|
|
# ---------------------------------------------------------------- read-only data
|
|
def test_measured_arrays_are_readonly_copies_that_later_frames_do_not_rewrite() -> None:
|
|
context = _context()
|
|
pipeline = ImagePipeline(
|
|
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
|
)
|
|
frames = _frames()
|
|
first = pipeline.measure(
|
|
frames[0],
|
|
identity=_identity(context, 0),
|
|
clock=sensor_clock(10, clock_id="host"),
|
|
include=("flow", "vnormal", "vshear", "field", "valid_mask"),
|
|
)
|
|
seen = {
|
|
"flow": np.array(first.flow, copy=True),
|
|
"vnormal": np.array(first.vnormal, copy=True),
|
|
"field": np.array(first.field["d"], copy=True),
|
|
}
|
|
for name, array in (
|
|
("flow", first.flow),
|
|
("vnormal", first.vnormal),
|
|
("field", first.field["d"]),
|
|
):
|
|
assert array.flags.writeable is False, name
|
|
with pytest.raises(ValueError):
|
|
array[0, 0] = 1.0
|
|
|
|
# Computing the next frame must not overwrite what was already published.
|
|
second = pipeline.measure(
|
|
frames[1],
|
|
identity=_identity(context, 1),
|
|
clock=sensor_clock(20, clock_id="host"),
|
|
include=("flow", "field"),
|
|
)
|
|
assert np.array_equal(first.flow, seen["flow"])
|
|
assert np.array_equal(first.vnormal, seen["vnormal"])
|
|
assert np.array_equal(first.field["d"], seen["field"])
|
|
assert not np.array_equal(second.flow, first.flow)
|
|
|
|
|
|
def test_include_selects_only_requested_arrays() -> None:
|
|
context = _context()
|
|
pipeline = ImagePipeline(
|
|
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
|
)
|
|
measurement = pipeline.measure(
|
|
_frames()[0],
|
|
identity=_identity(context),
|
|
clock=sensor_clock(1, clock_id="host"),
|
|
include=("flow",),
|
|
)
|
|
assert measurement.flow is not None
|
|
assert measurement.vnormal is None and measurement.vshear is None
|
|
assert measurement.field is None and measurement.valid_mask is None
|
|
with pytest.raises(ValueError, match="unknown include"):
|
|
pipeline.measure(
|
|
_frames()[0],
|
|
identity=_identity(context),
|
|
clock=sensor_clock(1, clock_id="host"),
|
|
include=("not-a-field",),
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------ reference-generation seam
|
|
class _GatedPipeline(ImagePipeline):
|
|
"""Barrier-style gate: hold a frame after its reference version was captured."""
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.entered = threading.Event()
|
|
self.release = threading.Event()
|
|
self.gate_once = True
|
|
|
|
def _compute_flow_state(self, image, *, valid_mask=None):
|
|
if self.gate_once:
|
|
self.gate_once = False
|
|
self.entered.set()
|
|
assert self.release.wait(timeout=5.0)
|
|
return super()._compute_flow_state(image, valid_mask=valid_mask)
|
|
|
|
|
|
def test_inflight_frame_keeps_its_own_reference_version() -> None:
|
|
"""The stream can adopt a new version while an older frame is still in flight."""
|
|
context = _context()
|
|
pipeline = _GatedPipeline(
|
|
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
|
)
|
|
frames = _frames()
|
|
results: list[ImageMeasurement] = []
|
|
|
|
def compute_first() -> None:
|
|
results.append(
|
|
pipeline.measure(
|
|
frames[0],
|
|
identity=_identity(context, 0),
|
|
clock=sensor_clock(100, clock_id="host"),
|
|
reference_generation=0,
|
|
)
|
|
)
|
|
|
|
worker = threading.Thread(target=compute_first)
|
|
worker.start()
|
|
try:
|
|
assert pipeline.entered.wait(timeout=5.0), "first frame never reached the gate"
|
|
# The stream adopts version 1 while frame 0 is provably in flight.
|
|
pipeline.set_reference_generation(1)
|
|
assert pipeline.reference_generation == 1
|
|
finally:
|
|
pipeline.release.set()
|
|
worker.join(timeout=5.0)
|
|
assert not worker.is_alive()
|
|
in_flight = results[0]
|
|
assert in_flight.frame.reference_generation == 0, "old frame was relabelled"
|
|
|
|
next_measurement = pipeline.measure(
|
|
frames[1],
|
|
identity=_identity(context, 1),
|
|
clock=sensor_clock(200, clock_id="host"),
|
|
reference_generation=1,
|
|
)
|
|
assert next_measurement.frame.reference_generation == 1
|
|
assert in_flight.frame.reference_generation == 0
|
|
|
|
|
|
def test_reference_version_is_adopted_not_counted_by_the_image_block() -> None:
|
|
""":class:`ImagePipeline` stamps the stream's version; a reset does not bump it."""
|
|
context = _context()
|
|
pipeline = ImagePipeline(
|
|
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
|
)
|
|
frames = _frames()
|
|
first = pipeline.measure(
|
|
frames[0],
|
|
identity=_identity(context, 0),
|
|
clock=sensor_clock(1, clock_id="host"),
|
|
reference_generation=0,
|
|
)
|
|
assert first.frame.reference_generation == 0
|
|
pipeline.reset_image_state()
|
|
zeroed = pipeline.measure(
|
|
frames[1],
|
|
identity=_identity(context, 1),
|
|
clock=sensor_clock(2, clock_id="host"),
|
|
reference_generation=0,
|
|
)
|
|
assert zeroed.frame.reference_generation == 0, "reset must not invent a version"
|
|
pipeline.set_reference_generation(3)
|
|
stamped = pipeline.measure(
|
|
frames[2],
|
|
identity=_identity(context, 2),
|
|
clock=sensor_clock(3, clock_id="host"),
|
|
reference_generation=3,
|
|
)
|
|
assert stamped.frame.reference_generation == 3
|
|
assert pipeline.reference_generation == 3
|
|
|
|
|
|
def _drive_pipeline(sensor: Sensor, frames: list[np.ndarray], *, request_reset: bool):
|
|
"""Run one StreamPipeline; record per-flow-frame adopted versions and snapshots."""
|
|
from orisys.pipeline import StreamPipeline
|
|
|
|
flow_versions: list[int] = []
|
|
snapshots: list = []
|
|
original = sensor.compute_flow
|
|
|
|
def recording_flow(*args, **kwargs):
|
|
flow_versions.append(int(sensor.image_pipeline.reference_generation))
|
|
return original(*args, **kwargs)
|
|
|
|
sensor.compute_flow = recording_flow
|
|
queue = iter(frames)
|
|
|
|
def produce():
|
|
try:
|
|
raw = next(queue)
|
|
except StopIteration:
|
|
return None
|
|
return sensor.get_img(frame=raw, already_processed=True, already_gray=True)
|
|
|
|
pipeline = StreamPipeline(
|
|
sensor, produce, include=(), on_frame=snapshots.append, verbose=False
|
|
)
|
|
if request_reset:
|
|
pipeline.request_reset()
|
|
pipeline.start()
|
|
try:
|
|
deadline = time.perf_counter() + 10.0
|
|
while pipeline.running and time.perf_counter() < deadline:
|
|
time.sleep(0.01)
|
|
assert pipeline.error is None
|
|
finally:
|
|
pipeline.stop()
|
|
sensor.compute_flow = original
|
|
snapshot_versions = [int(snapshot.tracker_reset_seq) for snapshot in snapshots]
|
|
return flow_versions, snapshot_versions
|
|
|
|
|
|
def test_pipeline_driven_reference_versions_match_every_snapshot_on_a_reused_sensor() -> None:
|
|
"""One version source: StreamPipeline's claimed version reaches the image block."""
|
|
sensor = Sensor(
|
|
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
|
)
|
|
frames = _frames()
|
|
try:
|
|
sensor.reset()
|
|
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=False)
|
|
assert flow_versions == snapshot_versions == [0, 0, 0, 0]
|
|
|
|
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=True)
|
|
assert flow_versions == snapshot_versions == [1, 1, 1, 1]
|
|
|
|
# A direct Sensor.reset() between two runs must not leave the image block
|
|
# on an old private counter: the next stream's version 0 wins.
|
|
sensor.reset()
|
|
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=False)
|
|
assert flow_versions == snapshot_versions == [0, 0, 0, 0]
|
|
assert sensor.image_pipeline.reference_generation == 0
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
# ------------------------------------------------------ published flow-EMA binding
|
|
def test_runtime_flow_ema_configuration_keeps_head_behaviour() -> None:
|
|
"""``Sensor.configure(flow_ema_alpha=...)`` must affect the next frame (HEAD)."""
|
|
from orisys.force_readout import ForceReadout
|
|
|
|
context = _context()
|
|
sensor = Sensor(
|
|
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
|
)
|
|
# HEAD-equivalent reference: the published Tracker/ForceReadout composition.
|
|
reference_readout = ForceReadout({"flow_ema_alpha": 1.0})
|
|
reference = ImagePipeline(
|
|
ImageSettings.from_config(
|
|
_config(), cuda=False, backend="cpu", flow_ema_alpha=1.0
|
|
),
|
|
flow_ema=reference_readout,
|
|
)
|
|
try:
|
|
for index, raw in enumerate(_frames()):
|
|
if index == 2:
|
|
sensor.configure(flow_ema_alpha=0.5)
|
|
reference_readout.configure(flow_ema_alpha=0.5)
|
|
prepared = sensor.get_img(
|
|
frame=raw, already_processed=True, already_gray=True
|
|
)
|
|
sensor.compute_flow()
|
|
sensor.compute_hhd()
|
|
measurement = reference.measure(
|
|
prepared,
|
|
identity=_identity(context, index),
|
|
clock=sensor_clock(1000 + index, clock_id="host"),
|
|
include=("flow", "vnormal"),
|
|
reference_generation=0,
|
|
)
|
|
assert sensor.image_pipeline.flow_ema_alpha == 0.5 if index >= 2 else True
|
|
assert np.array_equal(np.array(sensor.flow, copy=True), measurement.flow), index
|
|
assert np.array_equal(
|
|
np.array(sensor.vnormal, copy=True), measurement.vnormal
|
|
), index
|
|
sensor_force = tuple(
|
|
sensor.read_info(
|
|
sensor.info.FNORMAL_RAW,
|
|
sensor.info.FSHEARX_RAW,
|
|
sensor.info.FSHEARY_RAW,
|
|
)
|
|
)
|
|
assert sensor_force == measurement.force_raw, index
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
def test_static_flow_ema_config_is_bound_once(tmp_path) -> None:
|
|
"""A config-declared alpha<1 keeps the same flow through Sensor and a standalone pipeline."""
|
|
config = _config()
|
|
config["filters"] = dict(config.get("filters") or {}, flow_ema_alpha=0.5)
|
|
config_path = tmp_path / "box-ema05.json"
|
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
|
|
|
context = _context()
|
|
pipeline = ImagePipeline(
|
|
ImageSettings.from_config(config, cuda=False, backend="cpu")
|
|
)
|
|
sensor = Sensor(
|
|
"external",
|
|
config_name=str(config_path),
|
|
verbose=False,
|
|
backend="cpu",
|
|
cuda=False,
|
|
mirror=False,
|
|
)
|
|
try:
|
|
assert pipeline.settings.flow_ema_alpha == 0.5
|
|
assert sensor.image_pipeline.flow_ema_alpha == 0.5
|
|
for index, raw in enumerate(_frames()):
|
|
prepared = sensor.get_img(
|
|
frame=raw, already_processed=True, already_gray=True
|
|
)
|
|
sensor.compute_flow()
|
|
sensor.compute_hhd()
|
|
measurement = pipeline.measure(
|
|
prepared,
|
|
identity=_identity(context, index),
|
|
clock=sensor_clock(index, clock_id="host"),
|
|
include=("flow",),
|
|
reference_generation=0,
|
|
)
|
|
assert np.array_equal(np.array(sensor.flow, copy=True), measurement.flow), index
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
def test_flow_ema_matches_the_force_readout_recurrence() -> None:
|
|
from orisys.force_readout import ForceReadout
|
|
|
|
settings = ImageSettings.from_config(
|
|
_config(), cuda=False, backend="cpu", flow_ema_alpha=0.5
|
|
)
|
|
pipeline = ImagePipeline(settings)
|
|
readout = ForceReadout({"flow_ema_alpha": 0.5})
|
|
rng = np.random.default_rng(7)
|
|
for _ in range(4):
|
|
flow = rng.normal(0.0, 1.0, (6, 5, 2)).astype(np.float32)
|
|
pipeline_flow = pipeline._apply_flow_ema(flow.copy())
|
|
readout_flow = readout.apply_flow_ema(flow.copy())
|
|
assert np.array_equal(pipeline_flow, readout_flow)
|
|
pipeline.reset_image_state()
|
|
readout.reset_flow_ema()
|
|
flow = rng.normal(0.0, 1.0, (6, 5, 2)).astype(np.float32)
|
|
assert np.array_equal(
|
|
pipeline._apply_flow_ema(flow.copy()), readout.apply_flow_ema(flow.copy())
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------- delegation paths
|
|
def test_sensor_delegates_its_image_block_to_one_pipeline() -> None:
|
|
sensor = Sensor(
|
|
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
|
)
|
|
try:
|
|
assert isinstance(sensor.image_pipeline, ImagePipeline)
|
|
assert (
|
|
sensor.image_pipeline.settings.as_dict() == sensor.image_settings.as_dict()
|
|
)
|
|
sensor.get_img(frame=_frames()[0], already_processed=True, already_gray=True)
|
|
sensor.compute_flow()
|
|
assert sensor.flow is sensor.image_pipeline.flow
|
|
sensor.compute_hhd()
|
|
assert sensor.vnormal is sensor.image_pipeline.vnormal
|
|
assert sensor.vshear is sensor.image_pipeline.vshear
|
|
before = sensor.image_pipeline.reference_generation
|
|
sensor.reset_image_state()
|
|
# The reference version belongs to the stream; a local reset must not
|
|
# invent one. Only an adopted stream version changes it.
|
|
assert sensor.image_pipeline.reference_generation == before
|
|
sensor.set_stream_reference_generation(7)
|
|
assert sensor.image_pipeline.reference_generation == 7
|
|
# The published Newton/readout accessors keep working.
|
|
assert sensor.force_unit in ("N", "raw")
|
|
finally:
|
|
sensor.disconnect()
|
|
|
|
|
|
def test_finger_runtime_exposes_the_same_image_pipeline() -> None:
|
|
from orisys.finger.runtime import FingerRuntime
|
|
|
|
pipeline = object()
|
|
runtime = FingerRuntime.__new__(FingerRuntime)
|
|
runtime.tactile = type(
|
|
"Tactile", (), {"raw_sensor": type("Raw", (), {"image_pipeline": pipeline})()}
|
|
)()
|
|
assert runtime.image_pipeline is pipeline
|
|
|
|
runtime.tactile = type("Tactile", (), {"raw_sensor": object()})()
|
|
assert runtime.image_pipeline is None
|