06e06126ca
选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""A manually confirmed site produces one traceable trial directory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import time
|
|
from dataclasses import replace
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from orisys_app_shared.acquisition.models import SdkPacket
|
|
from orisys_autocal.trial import (
|
|
REFERENCE_HEADER,
|
|
ManualTrial,
|
|
load_path,
|
|
)
|
|
|
|
from orisys_autocal.sensor.uv_flat import FlatSurface, site_at, uv_origin
|
|
|
|
|
|
class Worker:
|
|
def __init__(self) -> None:
|
|
self.is_running = True
|
|
self.sinks = []
|
|
self.packet = SdkPacket(
|
|
raw_frame=np.zeros((32, 32, 3), dtype=np.uint8),
|
|
processed_frame=np.zeros((32, 32, 3), dtype=np.uint8),
|
|
timestamp=time.time(),
|
|
fps=30,
|
|
process_ms=1,
|
|
session_id=2,
|
|
frame_id=1,
|
|
mono_t=1.0,
|
|
capture_mono_ns=time.perf_counter_ns(),
|
|
capture_utc_ns=time.time_ns(),
|
|
source_frame_id=7,
|
|
time_basis="host_camera",
|
|
normal_force_raw=1.25,
|
|
)
|
|
|
|
def latest(self):
|
|
return self.packet
|
|
|
|
def add_sink(self, sink):
|
|
self.sinks.append(sink)
|
|
|
|
def remove_sink(self, sink):
|
|
self.sinks.remove(sink)
|
|
|
|
def emit(self):
|
|
for sink in self.sinks:
|
|
sink(self.packet)
|
|
|
|
def stop(self, *, wait=True):
|
|
self.is_running = False
|
|
|
|
|
|
@pytest.fixture
|
|
def flat():
|
|
mask = np.full((5, 7), -1, dtype=np.int32)
|
|
mask[1:4, 2:5] = 2
|
|
mask[4, 2] = 2 # A valid pixel may be below the centre-column origin.
|
|
positions = np.zeros((5, 7, 3), dtype=np.float64)
|
|
for y in range(5):
|
|
for x in range(7):
|
|
positions[y, x] = (x, y, 3)
|
|
normals = np.zeros_like(positions)
|
|
normals[:, :, 2] = 1
|
|
return FlatSurface((5, 7), np.zeros((5, 7, 3), dtype=np.uint8), positions,
|
|
normals, mask, (0, 0), (1, 1))
|
|
|
|
|
|
@pytest.fixture
|
|
def clock(monkeypatch):
|
|
from orisys_autocal import trial as trial_module
|
|
|
|
class Clock:
|
|
now = time.perf_counter_ns()
|
|
|
|
def advance(self, seconds):
|
|
self.now += round(seconds * 1e9)
|
|
|
|
value = Clock()
|
|
monkeypatch.setattr(trial_module, "time", SimpleNamespace(
|
|
perf_counter_ns=lambda: value.now, time_ns=time.time_ns))
|
|
return value
|
|
|
|
|
|
@pytest.fixture
|
|
def trial_setup(tmp_path, flat):
|
|
bundle = tmp_path / "bundle"
|
|
bundle.mkdir()
|
|
(bundle / "mesh.npz").write_bytes(b"mesh")
|
|
config = tmp_path / "sensor.json"
|
|
config.write_text("{}", encoding="utf-8")
|
|
return dict(root=tmp_path, site=site_at(flat, -1, 2), levels=[5, 10],
|
|
hold_s=20, sensor_model="Finger D40", sensor_sn="F-001",
|
|
bundle_path=str(bundle), source={"kind": "camera", "value": 0},
|
|
config_ref=str(config), fps=30)
|
|
|
|
|
|
def test_uv_coordinates_are_centered_and_masked(flat):
|
|
assert uv_origin(flat) == (3, 3)
|
|
site = site_at(flat, -1, 2)
|
|
assert site.raster_xy_px == (2, 1)
|
|
assert site.position_sensor_mm == (2.0, 1.0, 3.0)
|
|
assert site_at(flat, -1, -1).raster_xy_px == (2, 4)
|
|
with pytest.raises(ValueError):
|
|
site_at(flat, -3, 0)
|
|
|
|
|
|
def test_trial_persists_colour_region_outline_and_rejects_mismatched_mapping(trial_setup, flat):
|
|
from orisys_autocal.sensor.uv_outline import outline_snapshot
|
|
|
|
flat.textured = True
|
|
flat.image_rgb[:] = 255
|
|
flat.image_rgb[2:4, 2:5] = [20, 80, 200]
|
|
snapshot = outline_snapshot(flat)
|
|
worker = Worker()
|
|
trial = ManualTrial(worker)
|
|
with pytest.raises(ValueError, match="outline"):
|
|
trial.start(**trial_setup, uv_outline={**snapshot, "mapping_sha256": "wrong"})
|
|
directory = trial.start(**trial_setup, uv_outline=snapshot)
|
|
worker.emit()
|
|
trial.finish(aborted=True, reason="test ended")
|
|
trial.recorder.stop(wait=True)
|
|
trial.poll()
|
|
manifest = json.loads((directory.parent.parent / "session.json").read_text(encoding="utf-8"))
|
|
assert manifest["uv_outline"] == snapshot and snapshot["patch"]
|
|
trial.finish_session()
|
|
|
|
|
|
def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock):
|
|
worker = Worker()
|
|
trial = ManualTrial(worker)
|
|
assert load_path([5, 10]) == [
|
|
("zero", 0), ("load", 5), ("load", 10), ("unload", 5), ("unload", 0)]
|
|
directory = trial.start(**trial_setup)
|
|
worker.emit()
|
|
for index, value in enumerate((0, 5, 10, 5, 0)):
|
|
assert trial.step_index == index
|
|
trial.confirm(value)
|
|
assert trial.step_index == index
|
|
with pytest.raises(RuntimeError, match="hold"):
|
|
trial.confirm(value)
|
|
with pytest.raises(RuntimeError):
|
|
trial.finish()
|
|
clock.advance(19)
|
|
trial.poll()
|
|
assert trial.holding
|
|
assert trial.remaining_s == pytest.approx(1)
|
|
clock.advance(3) # Late UI polling must not extend the reference window.
|
|
trial.poll()
|
|
assert trial.step_index == index + 1
|
|
assert not trial.holding
|
|
clock.advance(4) # Adjustment time must have no constant-force label.
|
|
trial.finish()
|
|
assert trial.recorder.stop(wait=True).ok
|
|
assert trial.poll() == "complete"
|
|
assert directory.name == "trial-001"
|
|
assert "Finger-D40_F-001" in directory.parent.parent.name
|
|
assert len(list(directory.glob("raw_*.mp4"))) == 1
|
|
with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
|
|
row = next(csv.DictReader(handle))
|
|
assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns)
|
|
assert row["source_frame_id"] == "7"
|
|
assert row["time_basis"] == "host_camera"
|
|
with (directory / "reference.csv").open(encoding="utf-8", newline="") as handle:
|
|
reader = csv.DictReader(handle)
|
|
assert tuple(reader.fieldnames) == REFERENCE_HEADER
|
|
references = list(reader)
|
|
assert [row["branch"] for row in references] == ["zero", "load", "load", "unload", "unload"]
|
|
assert [float(row["F_ref_normal_N"]) for row in references] == [0, 5, 10, 5, 0]
|
|
assert all(row["F_ref_x_N"] == row["F_ref_y_N"] == row["F_ref_z_N"] == "" for row in references)
|
|
assert all(row["stability_verified"] == "0" and row["hold_complete"] == "1" for row in references)
|
|
assert all(int(r["t_end_host_mono_ns"]) - int(r["t_start_host_mono_ns"]) == 20_000_000_000 for r in references)
|
|
assert all(int(a["t_end_host_mono_ns"]) < int(b["t_start_host_mono_ns"]) for a, b in zip(references, references[1:]))
|
|
with (directory / "session.json").open(encoding="utf-8") as handle:
|
|
manifest = json.load(handle)
|
|
assert manifest["trial_status"] == "complete"
|
|
assert manifest["site"]["uv_xy_px"] == [-1, 2]
|
|
assert len(manifest["steps"]) == 5
|
|
assert manifest["uv_definition"]["mapping_sha256"] == trial_setup["site"].mapping_sha256
|
|
session_path = directory.parent.parent / "session.json"
|
|
root = json.loads(session_path.read_text(encoding="utf-8"))
|
|
assert root["status"] == "in_progress"
|
|
assert root["trials"][0]["status"] == "complete"
|
|
assert trial.finish_session()[1] == "complete"
|
|
assert json.loads(session_path.read_text(encoding="utf-8"))["status"] == "complete"
|
|
|
|
|
|
def test_retries_and_other_sites_share_one_session(trial_setup, clock):
|
|
worker = Worker()
|
|
trial = ManualTrial(worker)
|
|
directories = []
|
|
for site in (trial_setup["site"], trial_setup["site"],
|
|
replace(trial_setup["site"], u_px=0, raster_xy_px=(3, 1), position_sensor_mm=(3, 1, 3))):
|
|
directory = trial.start(**{**trial_setup, "site": site})
|
|
directories.append(directory)
|
|
worker.emit()
|
|
trial.confirm(0)
|
|
clock.advance(3)
|
|
trial.finish(aborted=True, reason="operator ended early")
|
|
trial.recorder.stop(wait=True)
|
|
assert trial.poll() == "incomplete"
|
|
rows = list(csv.DictReader((directory / "reference.csv").open(encoding="utf-8")))
|
|
assert rows[0]["hold_complete"] == "0"
|
|
assert len({d.parent.parent for d in directories}) == 1
|
|
root = trial.session.document
|
|
assert len(root["sites"]) == 2
|
|
assert [t["repeat_index"] for t in root["trials"]] == [1, 2, 1]
|
|
assert [t["site_id"] for t in root["trials"]] == ["site-001", "site-001", "site-002"]
|
|
with pytest.raises(ValueError, match="finish this session"):
|
|
trial.start(**{**trial_setup, "sensor_sn": "F-002"})
|
|
with pytest.raises(ValueError, match="finish this session"):
|
|
trial.start(**{**trial_setup, "site": replace(site, mapping_sha256="other")})
|
|
assert trial.finish_session()[1] == "incomplete"
|
|
new_directory = trial.start(**trial_setup)
|
|
assert new_directory.parent.parent != directories[0].parent.parent
|
|
trial.finish(aborted=True)
|
|
trial.recorder.stop(wait=True)
|
|
trial.poll()
|
|
trial.finish_session()
|
|
|
|
|
|
def test_input_end_aborts_active_hold(trial_setup, clock):
|
|
worker = Worker()
|
|
worker.packet = replace(worker.packet, time_basis="host_replay")
|
|
trial = ManualTrial(worker)
|
|
directory = trial.start(**trial_setup)
|
|
assert not trial.reference_alignment_possible
|
|
worker.emit()
|
|
trial.confirm(0)
|
|
clock.advance(2)
|
|
worker.is_running = False
|
|
trial.poll()
|
|
trial.recorder.stop(wait=True)
|
|
assert trial.poll() == "incomplete"
|
|
assert trial.entry["reason"] == "input or writer stopped"
|
|
assert json.loads((directory / "session.json").read_text(encoding="utf-8"))["steps"][0]["status"] == "incomplete"
|
|
trial.finish_session()
|
|
|
|
|
|
def test_a_trial_recorded_without_video_finishes_complete(trial_setup, clock):
|
|
"""Data-only recording: the force rows are the evidence and the verdict says so."""
|
|
from orisys_autocal.recording_options import RecordingOptions
|
|
|
|
worker = Worker()
|
|
trial = ManualTrial(worker)
|
|
directory = trial.start(**{**trial_setup, "recording": RecordingOptions(video=False)})
|
|
|
|
for index, (_branch, target) in enumerate(trial.steps):
|
|
trial.confirm(target)
|
|
# Two frames inside the second half of the hold: the summary's own rule.
|
|
# Each frame carries its own id: a repeated id has no single acquisition
|
|
# time and the review would exclude it.
|
|
for delay, frame_id in ((15, index * 2 + 2), (1, index * 2 + 3)):
|
|
clock.advance(delay)
|
|
worker.packet = replace(
|
|
worker.packet, capture_mono_ns=clock.now,
|
|
frame_id=frame_id, source_frame_id=frame_id,
|
|
)
|
|
worker.emit()
|
|
clock.advance(4)
|
|
trial.poll()
|
|
assert trial.step_index == index + 1
|
|
|
|
trial.finish()
|
|
assert trial.recorder.stop(wait=True).ok
|
|
assert trial.poll() == "complete"
|
|
assert not list(directory.glob("raw_*.mp4"))
|
|
root = json.loads(
|
|
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
|
|
)
|
|
assert root["recording"] == {
|
|
"video": False, "codec": "mp4v", "lossless_frames": False}
|
|
|
|
from orisys_autocal.summary import build_summary
|
|
|
|
summary = build_summary(directory.parent.parent)
|
|
assert all(row["eligible"] for row in summary["windows"]), [
|
|
(row["excluded_reason"], row["capture_status"], row["samples_valid"])
|
|
for row in summary["windows"]
|
|
]
|
|
assert all(row["capture_status"] == "complete" for row in summary["windows"])
|
|
trial.finish_session()
|
|
|
|
|
|
def test_recording_settings_are_locked_by_the_first_trial(trial_setup, clock):
|
|
"""One session, one recording choice: the manifest must not change mid-session."""
|
|
from orisys_autocal.recording_options import RecordingOptions
|
|
|
|
worker = Worker()
|
|
trial = ManualTrial(worker)
|
|
directory = trial.start(
|
|
**{**trial_setup, "recording": RecordingOptions(video=False)}
|
|
)
|
|
root = json.loads(
|
|
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
|
|
)
|
|
assert root["recording"] == {
|
|
"video": False, "codec": "mp4v", "lossless_frames": False}
|
|
|
|
worker.emit()
|
|
trial.finish(aborted=True, reason="test ended")
|
|
trial.recorder.stop(wait=True)
|
|
trial.poll()
|
|
|
|
with pytest.raises(ValueError, match="locked"):
|
|
trial.start(**{**trial_setup, "recording": RecordingOptions(video=True)})
|
|
# The locked settings are what a later trial of this session uses.
|
|
second = trial.start(
|
|
**{**trial_setup, "recording": RecordingOptions(video=False)}
|
|
)
|
|
assert trial.recording.video is False
|
|
assert second.parent.parent == directory.parent.parent
|
|
trial.recorder.stop(wait=True)
|
|
trial.poll()
|
|
trial.finish_session()
|
|
|
|
|
|
def test_the_capture_page_no_longer_drives_a_manual_trial(make_window):
|
|
"""D1: a new capture is a one-point queue in the ``sites/`` format.
|
|
|
|
The ManualTrial flow stays only so old ``trials/`` sessions keep reading; the
|
|
capture page owns a Campaign plus the workflow controller. The two-page
|
|
single/multi-point split it used to switch between is gone.
|
|
"""
|
|
window = make_window()
|
|
page = window.calibration_panel
|
|
assert not hasattr(page, "trial")
|
|
assert not hasattr(page, "multipoint")
|
|
assert page.campaign is not None
|
|
assert page.controller is not None
|
|
window.close()
|
|
|
|
|
|
def test_the_capture_page_keeps_the_picked_uv_point_while_the_live_view_is_visible(
|
|
make_window, qt_app
|
|
):
|
|
"""Both images are on the page at once; picking on the map keeps the point."""
|
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
|
|
|
window = make_window()
|
|
window.resize(1440, 900)
|
|
window.show()
|
|
qt_app.processEvents()
|
|
window.workspace.set_current_key("sensor")
|
|
qt_app.processEvents()
|
|
mask = np.full((5, 7), -1, dtype=np.int32)
|
|
mask[1:4, 2:5] = 2
|
|
positions = np.zeros((5, 7, 3), dtype=np.float64)
|
|
positions[1, 2] = (2, 1, 3)
|
|
flat = FlatSurface(
|
|
size_hw=(5, 7),
|
|
image_rgb=np.zeros((5, 7, 3), dtype=np.uint8),
|
|
positions_mm=positions,
|
|
normals=np.zeros_like(positions),
|
|
face_index=mask,
|
|
uv_min=(0, 0),
|
|
uv_max=(1, 1),
|
|
)
|
|
page = window.calibration_panel
|
|
page.set_flat(flat, "bundle")
|
|
page._picked(2, 1)
|
|
assert (page.u_spin.value(), page.v_spin.value()) == (-1, 2)
|
|
assert page._site.raster_xy_px == (2, 1)
|
|
assert "2.000" in page.position_value.text()
|
|
# The UV map and the live 2-D image are visible in the same row: selecting one
|
|
# used to hide the other behind a tab.
|
|
assert page.flat_view.isVisible() and page.live_view.isVisible()
|
|
window.close()
|
|
|
|
|
|
def test_the_session_row_opens_the_folder_the_summary_and_the_path(
|
|
make_window, tmp_path, monkeypatch
|
|
):
|
|
"""The operator can reach the recording without copying a path by hand."""
|
|
from PySide6.QtWidgets import QApplication
|
|
from orisys_app_shared.i18n import t
|
|
|
|
from evidence_helpers import multipoint_session
|
|
from orisys_autocal.summary import build_summary
|
|
from orisys_autocal.summary_export import publish_summary
|
|
from orisys_autocal.ui import calibration_panel as panel_module
|
|
|
|
window = make_window()
|
|
panel = window.calibration_panel
|
|
opened: list = []
|
|
monkeypatch.setattr(
|
|
panel_module, "reveal", lambda path, select=None: opened.append((path, select)) or True
|
|
)
|
|
|
|
# Nothing has been recorded yet: each action is disabled and says why.
|
|
panel._refresh()
|
|
assert not panel.open_session_button.isEnabled()
|
|
assert not panel.open_summary_button.isEnabled()
|
|
assert not panel.copy_path_button.isEnabled()
|
|
assert panel.open_session_button.toolTip() == t("cap.block.no_directory")
|
|
assert panel.open_summary_button.toolTip() == t("cap.block.no_summary")
|
|
|
|
root = multipoint_session(tmp_path / "session", sites=("P1",), repeats=1, levels=(5.0,))
|
|
publish_summary(root, build_summary(root))
|
|
# The page finds the session the campaign is recording into.
|
|
panel.campaign.directory = root
|
|
panel._refresh()
|
|
|
|
assert panel._session_directory() == root
|
|
assert panel.open_session_button.isEnabled()
|
|
assert panel.open_summary_button.isEnabled()
|
|
assert panel.copy_path_button.isEnabled()
|
|
assert panel.open_session_button.toolTip() == t("cap.action.open_session")
|
|
|
|
panel.open_session_button.click()
|
|
assert opened[-1][0] == root
|
|
panel.open_summary_button.click()
|
|
assert opened[-1][0] == root / "summary"
|
|
panel.copy_path_button.click()
|
|
assert QApplication.clipboard().text() == str(root)
|
|
window.close()
|