补齐 Autocal 实时反馈与稳定窗口复核
显示实时力值、曲线及 UV 图层,并保存人工确认窗口和零载检查基准。定向测试、真实视频预览与已录会话复核闭环验证通过。
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Live operator feedback must preserve sample validity and UV orientation."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
|
||||
|
||||
class Worker:
|
||||
is_running = True
|
||||
|
||||
def __init__(self):
|
||||
self.sinks = []
|
||||
|
||||
def add_sink(self, sink):
|
||||
self.sinks.append(sink)
|
||||
|
||||
def remove_sink(self, sink):
|
||||
self.sinks.remove(sink)
|
||||
|
||||
|
||||
def packet(**values):
|
||||
image = np.full((64, 80, 3), (10, 20, 30), dtype=np.uint8)
|
||||
base = SdkPacket(raw_frame=image.copy(), processed_frame=image,
|
||||
timestamp=100, mono_t=1, fps=30, process_ms=1,
|
||||
session_id=1, frame_id=1, normal_force_raw=12,
|
||||
shear_x_raw=2, shear_y_raw=-3)
|
||||
return replace(base, **values)
|
||||
|
||||
|
||||
def test_force_feedback_consumes_every_sample_and_breaks_invalid_gaps(qt_app):
|
||||
from orisys_autocal.ui.live_force_view import LiveForceView
|
||||
worker = Worker()
|
||||
view = LiveForceView(worker)
|
||||
for item in (packet(), packet(frame_id=2, mono_t=1.1, stale_forces=True),
|
||||
packet(frame_id=3, mono_t=1.2, normal_force_raw=20)):
|
||||
worker.sinks[0](item)
|
||||
view.refresh()
|
||||
assert view.history.sample_count == 3
|
||||
x, y = view.curves[0].getData()
|
||||
np.testing.assert_allclose(x, [0, .1, .2])
|
||||
assert y[0] == 12 and np.isnan(y[1]) and y[2] == 20
|
||||
assert "20 raw" in view.readings.text()
|
||||
assert "20.000 N" not in view.readings.text()
|
||||
view.on_packet(packet(session_id=2, normal_force=4, force_calibrated=True,
|
||||
calibrated_axes=("fn",)))
|
||||
view.refresh()
|
||||
assert view.history.sample_count == 1
|
||||
assert "4.000 N" in view.readings.text()
|
||||
assert "2.000 N" not in view.readings.text()
|
||||
worker.is_running = False
|
||||
view.refresh()
|
||||
assert view.state.text()
|
||||
view.shutdown()
|
||||
assert not worker.sinks
|
||||
|
||||
|
||||
def test_display_toggles_redraw_same_frame_without_mutating_measurements(qt_app, monkeypatch):
|
||||
from orisys_autocal.ui import finger_source_panel
|
||||
panel = finger_source_panel.FingerSourcePanel()
|
||||
item = packet(flow=np.full((64, 80, 2), 2., dtype=float),
|
||||
depth=np.full((64, 80), .5), valid_mask=np.ones((64, 80), bool),
|
||||
flow_valid=True, flow_frame_id=1, depth_valid=True, depth_frame_id=1)
|
||||
original = item.processed_frame.copy()
|
||||
panel._last_packet = item
|
||||
frames = []
|
||||
monkeypatch.setattr(panel, "_show_frame", lambda frame: frames.append(frame.copy()))
|
||||
panel._redraw()
|
||||
np.testing.assert_array_equal(frames[-1], original)
|
||||
panel.background_check.setChecked(False)
|
||||
assert not np.array_equal(frames[-1], original)
|
||||
solid = frames[-1]
|
||||
panel.depth_check.setChecked(True)
|
||||
assert not np.array_equal(frames[-1], solid)
|
||||
depth = frames[-1]
|
||||
panel.arrows_check.setChecked(True)
|
||||
assert not np.array_equal(frames[-1], depth)
|
||||
assert frames[-1].shape == original.shape
|
||||
np.testing.assert_array_equal(item.processed_frame, original)
|
||||
assert np.all(item.flow == 2)
|
||||
panel._last_packet = replace(item, flow_valid=False, depth_frame_id=0)
|
||||
panel._redraw()
|
||||
assert panel.display_note.text()
|
||||
np.testing.assert_array_equal(frames[-1], solid)
|
||||
panel.shutdown()
|
||||
@@ -122,3 +122,91 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
|
||||
qt_app.processEvents()
|
||||
assert dialog.grab().save(str(tmp_path / "review.png"))
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_reviewed_windows_roundtrip_and_reject_changed_inputs(session):
|
||||
from orisys_autocal.session_review import load_review
|
||||
|
||||
selections = {f"trial-001:{index}": {
|
||||
"t_start_host_mono_ns": (index * 20 + 1) * 1_000_000_000,
|
||||
"t_end_host_mono_ns": (index * 20 + 3) * 1_000_000_000,
|
||||
"stability_verified": True,
|
||||
} for index in (0, 1, 2)}
|
||||
before = (session / "trials/trial-001/reference.csv").read_bytes()
|
||||
result = review_session(session, selections=selections)
|
||||
first = result["sites"][0]
|
||||
assert first["confirmed_fit"]["gain_n_per_raw"] == pytest.approx(.5)
|
||||
assert all(w["duration_s"] == 2 for w in first["windows"][:3])
|
||||
saved = save_review(result)
|
||||
assert load_review(session, saved)["sites"] == result["sites"]
|
||||
document = json.loads(saved.read_text(encoding="utf-8"))
|
||||
assert document["baselines"][0]["status"] == "unavailable"
|
||||
assert document["baselines"][0]["tracker_reference"] is False
|
||||
assert document["analysis_versions"]["numpy"]
|
||||
assert (session / "trials/trial-001/reference.csv").read_bytes() == before
|
||||
with (session / "trials/trial-001/forces.csv").open("a", encoding="utf-8") as handle:
|
||||
handle.write("\n")
|
||||
with pytest.raises(ValueError, match="match"):
|
||||
load_review(session, saved)
|
||||
with pytest.raises(ValueError, match="changed"):
|
||||
save_review(result)
|
||||
|
||||
|
||||
def test_invalid_review_selection_does_not_become_verified(session):
|
||||
for start, end in ((0, 2), (1, 1), (1, 12), (3, 4)):
|
||||
with pytest.raises(ValueError):
|
||||
review_session(session, selections={"trial-001:0": {
|
||||
"t_start_host_mono_ns": start * 1_000_000_000,
|
||||
"t_end_host_mono_ns": end * 1_000_000_000,
|
||||
"stability_verified": True,
|
||||
}})
|
||||
|
||||
|
||||
def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, monkeypatch):
|
||||
from orisys_autocal.ui import session_review_dialog
|
||||
|
||||
dialog = session_review_dialog.SessionReviewDialog(session)
|
||||
for row in (0, 1):
|
||||
dialog.table.selectRow(row)
|
||||
dialog.end_s.setValue(2)
|
||||
dialog.verified.setChecked(True)
|
||||
dialog.apply_button.click()
|
||||
assert dialog.review["sites"][0]["windows"][row]["stability_verified"]
|
||||
assert dialog.review["sites"][0]["confirmed_fit"] is not None
|
||||
dialog.save_button.click()
|
||||
saved = next(session.glob("derived/*/review.json"))
|
||||
fresh = session_review_dialog.SessionReviewDialog(session)
|
||||
monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName",
|
||||
lambda *args: (str(saved), ""))
|
||||
fresh.load_button.click()
|
||||
assert fresh.review["sites"] == dialog.review["sites"]
|
||||
dialog.close()
|
||||
fresh.close()
|
||||
|
||||
|
||||
def test_baseline_image_matches_first_reviewed_zero_frame(session):
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
path = session / "trials/trial-001"
|
||||
frames = list(csv.DictReader((path / "frames.csv").open(encoding="utf-8")))
|
||||
writer = cv2.VideoWriter(str(path / "raw_0001.mp4"), cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
30, (32, 24))
|
||||
assert writer.isOpened()
|
||||
try:
|
||||
for index, row in enumerate(frames):
|
||||
writer.write(np.full((24, 32, 3), 20 if index == 0 else 220, np.uint8))
|
||||
row.update(segment=1, segment_frame=index + 1, in_video=1)
|
||||
finally:
|
||||
writer.release()
|
||||
_csv(path / "frames.csv", frames)
|
||||
result = review_session(session, selections={"trial-001:0": {
|
||||
"t_start_host_mono_ns": 1_000_000_000,
|
||||
"t_end_host_mono_ns": 3_000_000_000, "stability_verified": True,
|
||||
}})
|
||||
saved = save_review(result)
|
||||
document = json.loads(saved.read_text(encoding="utf-8"))
|
||||
baseline = document["baselines"][0]
|
||||
assert baseline["status"] == "saved" and baseline["segment_frame"] == 1
|
||||
image = cv2.imread(str(saved.parent / baseline["image"]))
|
||||
assert 10 < image.mean() < 30
|
||||
|
||||
Reference in New Issue
Block a user