Files
orisys-sdk/tests/test_image_pipeline.py
T
ykcheungab e3b60f3d6d feat: 将图像块拆分为可独立运行的 ImagePipeline
光流、HHD 和场从 Sensor 中抽到 ImageSettings/ImagePipeline,Sensor 与 FingerRuntime 改为委托,公开接口与数值不变(222 个基线数组逐位一致,含运行时 flow_ema_alpha 场景)。参考版本只有一个来源:StreamPipeline 在 flow 线程领取的版本逐帧交给图像块。对外数组为只读副本;输出带 stream-contract 的 MeasurementFrame 头。回归 357 项、lint 通过。
2026-10-21 13:35:02 +08:00

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