Files
eco/tests/test_camserver_stream_qt.py
2026-09-11 16:04:34 +02:00

1361 lines
47 KiB
Python

import time
import numpy as np
import pytest
pytest.importorskip("qtpy")
from qtpy import QtCore, QtWidgets
from eco.widgets.camserver_stream_qt import (
CamServerStreamQt,
FrameProcessor,
LivePipelineFields,
ScreenpanelAnalysis,
_HistogramColorbar,
_StreamWorker,
_build_cam_from_argv,
_main,
capture_camera_snapshot,
default_pipeline_name,
normalize_to_uint8,
resolve_camera_pipeline,
resolve_stream,
)
# real PV/cam_server names this feature was built against: SARES20-PROF141-M1
# is the CameraBasler behind eco.xdiagnostics.profile_monitors.ProfKbBernina,
# SARES20-PROF146-M1 the CameraPCO behind
# eco.xdiagnostics.profile_monitors.Pprm_dsd (see tests/test_cameras_swissfel.py
# for the ._widget_viewer()-level tests using these same two names)
BASLER_CAMERA_NAME = "SARES20-PROF141-M1"
PCO_CAMERA_NAME = "SARES20-PROF146-M1"
def test_normalize_to_uint8_full_range():
arr = np.array([[0, 128], [255, 64]], dtype=np.uint16)
out = normalize_to_uint8(arr)
assert out.dtype == np.uint8
assert out.min() == 0
assert out.max() == 255
def test_normalize_to_uint8_fixed_range_clips():
arr = np.array([[-10, 0], [50, 200]], dtype=np.float32)
out = normalize_to_uint8(arr, vmin=0, vmax=100)
assert out[0, 0] == 0 # clipped below vmin
assert out[1, 1] == 255 # clipped above vmax
assert out[1, 0] == pytest.approx(127, abs=2)
def test_normalize_to_uint8_flat_image_returns_zeros():
arr = np.full((4, 4), 7, dtype=np.uint16)
out = normalize_to_uint8(arr)
assert np.all(out == 0)
def test_normalize_to_uint8_rejects_non_2d():
with pytest.raises(ValueError):
normalize_to_uint8(np.zeros((2, 2, 3)))
# -- FrameProcessor averaging: "running" (continuously-updated sliding
# window, the pre-existing default) vs "single" (accumulate N frames, emit
# their mean once, then start a fresh batch) -- mirrors pshell's own
# CamServerViewer.ImageIntegrator (decompiled from pshell-workbench: a
# negative "integration" count recomputes the mean over the last N frames
# on every new frame; non-negative accumulates N frames and emits their sum
# once every N frames).
def test_running_average_updates_every_frame():
proc = FrameProcessor(average_n=3)
assert proc.average_mode == "running"
frames = [np.full((2, 2), v, dtype=np.float32) for v in (10, 20, 30, 40)]
seen = [proc.process(f)[1]["values"][0, 0] for f in frames]
# sliding window of size 3: 10, (10+20)/2, (10+20+30)/3, (20+30+40)/3
assert seen == pytest.approx([10, 15, 20, 30])
def test_single_average_only_refreshes_every_n_frames():
proc = FrameProcessor(average_n=3)
proc.set_average_mode("single")
frames = [np.full((2, 2), v, dtype=np.float32) for v in (10, 20, 30, 40, 50, 60, 70)]
seen = [proc.process(f)[1]["values"][0, 0] for f in frames]
# first batch (10,20,30) not complete until frame 3 -> raw frames shown
# meanwhile; then the mean (20) holds through frames 4-5 (next batch
# still filling); frame 6 completes batch 2 (40,50,60) -> mean 50 holds
# through frame 7
assert seen == pytest.approx([10, 20, 20, 20, 20, 50, 50])
def test_set_average_mode_rejects_unknown_value():
proc = FrameProcessor()
with pytest.raises(ValueError):
proc.set_average_mode("bogus")
def test_set_average_mode_resets_in_progress_batch():
proc = FrameProcessor(average_n=3)
proc.set_average_mode("single")
proc.process(np.full((2, 2), 10, dtype=np.float32))
proc.process(np.full((2, 2), 20, dtype=np.float32)) # batch not yet complete
proc.set_average_mode("running")
proc.set_average_mode("single") # back to single -- should start a clean batch
frames = [np.full((2, 2), v, dtype=np.float32) for v in (1, 2, 3)]
seen = [proc.process(f)[1]["values"][0, 0] for f in frames]
assert seen == pytest.approx([1, 2, 2]) # mean(1,2,3)=2 lands on frame 3, not before
def test_default_pipeline_name_matches_pshell_convention():
# ch.psi.pshell.screenpanel.CamServerViewer's default pipelineNameFormat
# ("%s_sp") decompiled from pshell-workbench -- see
# DEFAULT_PIPELINE_NAME_FORMAT's docstring
assert default_pipeline_name(BASLER_CAMERA_NAME) == "SARES20-PROF141-M1_sp"
assert default_pipeline_name(PCO_CAMERA_NAME) == "SARES20-PROF146-M1_sp"
def test_resolve_camera_pipeline_prefers_running_instance(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
assert name == "SARES20-PROF141-M1_sp"
return "tcp://example.psi.ch:9999"
def get_pipelines(self):
raise AssertionError("should not need to look at pipeline configs")
def create_instance_from_name(self, name):
raise AssertionError("should not create a new instance")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
pipeline_name, address = resolve_camera_pipeline(BASLER_CAMERA_NAME)
assert pipeline_name == "SARES20-PROF141-M1_sp"
assert address == "tcp://example.psi.ch:9999"
def test_resolve_camera_pipeline_creates_config_when_missing(monkeypatch):
"""The piece pshell's screen panel has that plain resolve_stream(kind=
"pipeline") doesn't: not just reuse-or-create-an-instance-of-an-existing-
config, but first auto-create the pipeline *config* itself (with just
camera_name set) if even that doesn't exist yet."""
cam_server = pytest.importorskip("cam_server")
calls = []
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
raise RuntimeError("not running")
def get_pipelines(self):
return ["some_other_pipeline"]
def save_pipeline_config(self, name, config):
calls.append(("save", name, config))
def create_instance_from_name(self, name):
calls.append(("create", name))
return ("generated-id", "tcp://example.psi.ch:8888")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
pipeline_name, address = resolve_camera_pipeline(PCO_CAMERA_NAME)
assert pipeline_name == "SARES20-PROF146-M1_sp"
assert address == "tcp://example.psi.ch:8888"
assert calls == [
("save", "SARES20-PROF146-M1_sp", {"camera_name": "SARES20-PROF146-M1"}),
("create", "SARES20-PROF146-M1_sp"),
]
def test_resolve_camera_pipeline_reuses_existing_config_without_resaving(monkeypatch):
cam_server = pytest.importorskip("cam_server")
calls = []
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
raise RuntimeError("not running")
def get_pipelines(self):
return ["SARES20-PROF141-M1_sp"]
def save_pipeline_config(self, name, config):
calls.append(("save", name, config))
def create_instance_from_name(self, name):
calls.append(("create", name))
return ("generated-id", "tcp://example.psi.ch:8888")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
resolve_camera_pipeline(BASLER_CAMERA_NAME)
assert calls == [("create", "SARES20-PROF141-M1_sp")] # no "save" call
def test_resolve_camera_pipeline_create_false_reraises(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
raise RuntimeError("not running")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
with pytest.raises(RuntimeError):
resolve_camera_pipeline(BASLER_CAMERA_NAME, create=False)
def test_resolve_stream_camera_pipeline_delegates(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
assert name == "SARES20-PROF146-M1_sp"
return "tcp://example.psi.ch:7777"
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
address = resolve_stream(PCO_CAMERA_NAME, kind="camera_pipeline")
assert address == "tcp://example.psi.ch:7777"
def test_resolve_stream_pipeline_prefers_running_instance(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
self.url = url
def get_instance_stream(self, name):
assert name == "my_pipeline"
return "tcp://example.psi.ch:9999"
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
address = resolve_stream("my_pipeline", kind="pipeline")
assert address == "tcp://example.psi.ch:9999"
def test_resolve_stream_pipeline_creates_when_not_running(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
raise RuntimeError("not running")
def create_instance_from_name(self, name):
return ("generated-id", "tcp://example.psi.ch:8888")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
address = resolve_stream("my_pipeline", kind="pipeline")
assert address == "tcp://example.psi.ch:8888"
def test_resolve_stream_pipeline_create_false_reraises(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
raise RuntimeError("not running")
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
with pytest.raises(RuntimeError):
resolve_stream("my_pipeline", kind="pipeline", create=False)
def test_resolve_stream_camera_uses_camclient(monkeypatch):
cam_server = pytest.importorskip("cam_server")
class FakeCamClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
assert name == "my_camera"
return "tcp://cam.psi.ch:1111"
monkeypatch.setattr(cam_server, "CamClient", FakeCamClient)
address = resolve_stream("my_camera", kind="camera")
assert address == "tcp://cam.psi.ch:1111"
class _RecordingSignal:
def __init__(self):
self.calls = []
def emit(self, value):
self.calls.append(value)
class _RecordingBridge:
def __init__(self):
self.frame_ready = _RecordingSignal()
self.error = _RecordingSignal()
self.status = _RecordingSignal()
def test_stream_worker_emits_decoded_frames_and_stops_cleanly(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
class FakeValue:
def __init__(self, value):
self.value = value
class FakeCompactMessage:
def __init__(self, pulse_id, image):
self.pulse_id = pulse_id
self.data = {"image": FakeValue(image)}
class FakeMflowMessage:
def __init__(self, data):
self.data = data
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
return "tcp://fake-host:1234"
class FakeSource:
instances = []
def __init__(self, host, port, mode, queue_size, receive_timeout):
self.host, self.port = host, port
self._n = 0
FakeSource.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def receive(self):
self._n += 1
arr = np.full((2, 2), self._n % 256, dtype=np.uint16)
return FakeMflowMessage(FakeCompactMessage(self._n, arr))
FakeSource.instances = []
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
monkeypatch.setattr(bsread, "Source", FakeSource)
bridge = _RecordingBridge()
worker = _StreamWorker("my_pipeline", "pipeline", bridge)
worker.start()
try:
for _ in range(100):
if len(bridge.frame_ready.calls) >= 3:
break
time.sleep(0.02)
finally:
worker.stop()
worker.join(timeout=2)
assert not worker.is_alive()
assert len(bridge.frame_ready.calls) >= 3
array, pulse_id = bridge.frame_ready.calls[0]
assert array.shape == (2, 2)
assert pulse_id == 1
assert FakeSource.instances[0].host == "fake-host"
assert FakeSource.instances[0].port == 1234
assert bridge.status.calls == ["connected to tcp://fake-host:1234"]
def test_stream_worker_pause_stops_new_frames(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
class FakeValue:
def __init__(self, value):
self.value = value
class FakeCompactMessage:
def __init__(self, pulse_id, image):
self.pulse_id = pulse_id
self.data = {"image": FakeValue(image)}
class FakeMflowMessage:
def __init__(self, data):
self.data = data
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
return "tcp://fake-host:1234"
class FakeSource:
def __init__(self, host, port, mode, queue_size, receive_timeout):
self._n = 0
def __enter__(self):
return self
def __exit__(self, *a):
return False
def receive(self):
self._n += 1
arr = np.full((2, 2), self._n % 256, dtype=np.uint16)
return FakeMflowMessage(FakeCompactMessage(self._n, arr))
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
monkeypatch.setattr(bsread, "Source", FakeSource)
bridge = _RecordingBridge()
worker = _StreamWorker("my_pipeline", "pipeline", bridge)
worker.start()
try:
for _ in range(100):
if len(bridge.frame_ready.calls) >= 2:
break
time.sleep(0.02)
worker.pause(True)
# give the in-flight receive()/emit() (if any) a moment to land,
# then confirm no further frames arrive while paused
time.sleep(0.1)
count_at_pause = len(bridge.frame_ready.calls)
time.sleep(0.3)
assert len(bridge.frame_ready.calls) == count_at_pause
finally:
worker.stop()
worker.join(timeout=2)
# -- LivePipelineFields / ScreenpanelAnalysis (headless, no Qt needed) ------
def _install_fake_analysis_stream(monkeypatch, cam_server, bsread, messages):
"""messages: a list of {field_name: raw_value} dicts, each yielded once
(in order) by a fake bsread.Source, then further receive() calls
return None (simulating an idle stream -- no more data, not a real
error) -- mirrors the FakePipelineClient/FakeSource pattern used
elsewhere in this file."""
class FakeValue:
def __init__(self, value):
self.value = value
class FakeCompactMessage:
def __init__(self, pulse_id, fields):
self.pulse_id = pulse_id
self.data = {k: FakeValue(v) for k, v in fields.items()}
class FakeMflowMessage:
def __init__(self, data):
self.data = data
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
return "tcp://fake-host:1234"
class FakeSource:
def __init__(self, host, port, mode, queue_size, receive_timeout):
self._n = 0
def __enter__(self):
return self
def __exit__(self, *a):
return False
def receive(self):
if self._n >= len(messages):
return None
fields = messages[self._n]
self._n += 1
return FakeMflowMessage(FakeCompactMessage(self._n, fields))
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
monkeypatch.setattr(bsread, "Source", FakeSource)
def test_live_pipeline_fields_caches_scalar_fields_excluding_image(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_analysis_stream(
monkeypatch, cam_server, bsread,
[
{"image": np.zeros((2, 2)), "intensity": 100.0, "x_center_of_mass": 5.0},
{"image": np.zeros((2, 2)), "intensity": 110.0, "x_center_of_mass": 5.2},
],
)
fields = LivePipelineFields("my_camera", kind="pipeline")
fields.start()
try:
assert fields.wait_for_field("intensity", timeout=2.0) == pytest.approx(110.0)
assert fields.wait_for_field("x_center_of_mass", timeout=2.0) == pytest.approx(5.2)
assert set(fields.field_names()) == {"intensity", "x_center_of_mass"}
assert "image" not in fields.field_names()
finally:
fields.stop()
def test_live_pipeline_fields_get_current_value_raises_for_unseen_field():
fields = LivePipelineFields("my_camera", kind="pipeline")
with pytest.raises(KeyError):
fields.get_current_value("intensity")
def test_live_pipeline_fields_wait_for_field_times_out(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_analysis_stream(monkeypatch, cam_server, bsread, [])
fields = LivePipelineFields("my_camera", kind="pipeline")
fields.start()
try:
with pytest.raises(TimeoutError):
fields.wait_for_field("intensity", timeout=0.2)
finally:
fields.stop()
def test_screenpanel_analysis_field_access_returns_a_live_detector(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_analysis_stream(
monkeypatch, cam_server, bsread, [{"image": np.zeros((2, 2)), "intensity": 42.0}]
)
ana = ScreenpanelAnalysis("my_camera", kind="pipeline")
try:
det = ana.intensity
assert det.get_current_value() == pytest.approx(42.0) # Detector protocol
assert det.name == "my_camera.intensity"
finally:
ana.stop()
def test_screenpanel_analysis_reuses_one_subscriber_across_fields(monkeypatch):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_analysis_stream(
monkeypatch, cam_server, bsread,
[{"image": np.zeros((2, 2)), "intensity": 1.0, "x_center_of_mass": 2.0}],
)
ana = ScreenpanelAnalysis("my_camera", kind="pipeline")
try:
# accessing two different fields must share the same background
# subscriber/connection, not open one per field
assert ana.intensity._fields is ana.x_center_of_mass._fields
finally:
ana.stop()
# -- capture_camera_snapshot (headless: no Qt window/QApplication needed) ---
def _install_fake_frames(monkeypatch, cam_server, bsread, frames):
"""Wire up a fake PipelineClient + bsread.Source that yields exactly
`frames` (a list of 2D arrays), in order, then simulates a receive
timeout -- mirrors the FakePipelineClient/FakeSource pattern used by
the _StreamWorker tests above, factored out since
capture_camera_snapshot's tests need several variations of it."""
class FakeValue:
def __init__(self, value):
self.value = value
class FakeCompactMessage:
def __init__(self, pulse_id, image):
self.pulse_id = pulse_id
self.data = {"image": FakeValue(image)}
class FakeMflowMessage:
def __init__(self, data):
self.data = data
class FakePipelineClient:
def __init__(self, url=None):
pass
def get_instance_stream(self, name):
return "tcp://fake-host:1234"
class FakeSource:
def __init__(self, host, port, mode, queue_size, receive_timeout):
self._n = 0
def __enter__(self):
return self
def __exit__(self, *a):
return False
def receive(self):
if self._n >= len(frames):
return None # simulates a real receive timeout
arr = frames[self._n]
self._n += 1
return FakeMflowMessage(FakeCompactMessage(self._n, arr))
monkeypatch.setattr(cam_server, "PipelineClient", FakePipelineClient)
monkeypatch.setattr(bsread, "Source", FakeSource)
def _install_fake_stream(monkeypatch, cam_server, bsread, frame_values, shape=(4, 4)):
"""_install_fake_frames, but each entry in `frame_values` is a scalar
-- yields one constant-valued frame per entry (convenient where the
test just wants easy-to-check-the-mean-of values, not distinguishable
image content)."""
frames = [np.full(shape, v, dtype=np.float32) for v in frame_values]
_install_fake_frames(monkeypatch, cam_server, bsread, frames)
def test_capture_camera_snapshot_writes_a_png(monkeypatch, tmp_path):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_stream(monkeypatch, cam_server, bsread, [42])
path, stats = capture_camera_snapshot("my_pipeline", kind="pipeline", out_dir=tmp_path)
assert path.exists()
assert path.parent == tmp_path
assert path.suffix == ".png"
assert stats["shape"] == (4, 4)
def test_capture_camera_snapshot_averages_n_raw_frames_per_output(monkeypatch, tmp_path):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_stream(monkeypatch, cam_server, bsread, [10, 20, 30])
path, stats = capture_camera_snapshot(
"my_pipeline", kind="pipeline", n_average=3, out_dir=tmp_path
)
assert stats["values"][0, 0] == pytest.approx(20.0) # mean(10, 20, 30)
path.unlink()
def test_capture_camera_snapshot_animate_writes_a_multi_frame_gif(monkeypatch, tmp_path):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
from PIL import Image
# distinguishable (non-flat) content per frame, and a fixed manual
# contrast range: a *constant* array would hit normalize_to_uint8's
# flat-image special case (-> all zeros) making every frame identical;
# "auto" contrast (each frame normalized to its own min/max) would
# even make an offset ramp like this look identical after normalizing
# (linear rescale is offset/scale-invariant for a plain ramp) -- an
# explicit manual range makes the raw offset differences actually show
# up in the saved pixels, which is what this test wants to check
frames = [np.arange(16).reshape(4, 4).astype(np.float32) + offset for offset in (0, 100, 200)]
_install_fake_frames(monkeypatch, cam_server, bsread, frames)
path, stats = capture_camera_snapshot(
"my_pipeline", kind="pipeline", animate=True, n_frames=3, out_dir=tmp_path,
contrast_mode="manual", vmin=0, vmax=300,
)
assert path.suffix == ".gif"
with Image.open(path) as gif:
frame_count = 0
try:
while True:
gif.seek(frame_count)
frame_count += 1
except EOFError:
pass
assert frame_count == 3
path.unlink()
def test_capture_camera_snapshot_raises_on_timeout(monkeypatch, tmp_path):
cam_server = pytest.importorskip("cam_server")
bsread = pytest.importorskip("bsread")
_install_fake_stream(monkeypatch, cam_server, bsread, []) # never yields a frame
with pytest.raises(TimeoutError):
capture_camera_snapshot("my_pipeline", kind="pipeline", out_dir=tmp_path, receive_timeout=0.01)
# -- "Camera Settings" button (cam=) -----------------------------------------
# kind="demo" throughout: exercises the cam=/_open_settings wiring with real
# Qt widgets but with no cam_server/network dependency at all (the demo
# source needs neither).
class _FakeCamAssembly:
"""Stand-in for a CameraBasler/CameraPCO: only .widget(normal=...)
matters here -- see CameraBasler._widget_viewer()/CamServerStreamQt._open_settings."""
def __init__(self):
self.widget_calls = []
def widget(self, normal=False):
self.widget_calls.append(normal)
return "the settings widget"
def _find_action(window, text):
matches = [a for a in window.findChildren(QtWidgets.QAction) if a.text() == text]
assert len(matches) == 1, f"expected exactly one {text!r} action, found {len(matches)}"
return matches[0]
def test_camera_settings_button_present_when_cam_given():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = _FakeCamAssembly()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window()
action = _find_action(gui.window, "Camera Settings")
action.trigger()
# normal=True: opens the plain property grid, not this same viewer
# again (CameraBasler/CameraPCO set _default_widget = "_widget_viewer") --
# mirrors AxisPTZStreamQt._open_settings
assert fake_cam.widget_calls == [True]
assert gui._settings_window == "the settings widget"
finally:
gui.stop()
def test_camera_settings_button_absent_without_cam():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
matches = [a for a in gui.window.findChildren(QtWidgets.QAction) if a.text() == "Camera Settings"]
assert matches == []
finally:
gui.stop()
# -- window title: eco device name vs. raw pvname/pipeline name --------
def test_window_title_uses_eco_name_when_given():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", eco_name="bernina.cam1", auto_start=False)
try:
gui._build_window()
assert gui.window.windowTitle() == "cam_server stream - bernina.cam1"
finally:
gui.stop()
def test_window_title_falls_back_to_name_without_eco_name():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
assert gui.window.windowTitle() == "cam_server stream - demo"
finally:
gui.stop()
# -- --eco-name / --cam-class CLI plumbing (separate-process viewer) ---
def test_main_parses_eco_name_and_cam_class_flags(monkeypatch):
captured = {}
class _FakeViewer:
def __init__(self, *args, **kwargs):
captured.update(kwargs)
def run(self):
captured["ran"] = True
monkeypatch.setattr("eco.widgets.camserver_stream_qt.CamServerStreamQt", _FakeViewer)
# a nonexistent class path -- _build_cam_from_argv degrades to cam=None
# rather than raising, so this doesn't need a real importable class
_main(["demo", "--kind", "demo", "--eco-name", "bernina.cam1", "--cam-class", "not.a.real.Class"])
assert captured["eco_name"] == "bernina.cam1"
assert captured["cam"] is None
assert captured["ran"] is True
def test_build_cam_from_argv_returns_none_and_logs_on_import_failure():
assert _build_cam_from_argv("not.a.real.module.Class", "SOME-PV") is None
def test_build_cam_from_argv_constructs_the_named_class(monkeypatch):
import sys
import types
calls = []
class _FakeCam:
def __init__(self, pvname):
calls.append(pvname)
fake_module = types.SimpleNamespace(FakeCam=_FakeCam)
monkeypatch.setitem(sys.modules, "eco_test_fake_cam_module", fake_module)
result = _build_cam_from_argv("eco_test_fake_cam_module.FakeCam", "PVNAME")
assert isinstance(result, _FakeCam)
assert calls == ["PVNAME"]
def test_build_cam_from_argv_falls_back_to_none_on_construction_failure(monkeypatch):
import sys
import types
class _FakeCamBoom:
def __init__(self, pvname):
raise RuntimeError("EPICS unreachable")
fake_module = types.SimpleNamespace(FakeCamBoom=_FakeCamBoom)
monkeypatch.setitem(sys.modules, "eco_test_fake_cam_module_boom", fake_module)
result = _build_cam_from_argv("eco_test_fake_cam_module_boom.FakeCamBoom", "PVNAME")
assert result is None
# -- drag-to-zoom -------------------------------------------------------
def test_zoom_drag_toggle_is_mutually_exclusive_with_roi_and_marker():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._zoom_drag_btn.setChecked(True)
assert gui._label.interaction_mode == "zoom"
assert not gui._roi_btn.isChecked()
assert not gui._marker_btn.isChecked()
gui._roi_btn.setChecked(True)
assert gui._label.interaction_mode == "roi"
assert not gui._zoom_drag_btn.isChecked()
gui._zoom_drag_btn.setChecked(True)
gui._marker_btn.setChecked(True)
assert gui._label.interaction_mode == "marker"
assert not gui._zoom_drag_btn.isChecked()
finally:
gui.stop()
def test_zoom_dragged_sets_custom_scale_and_pending_center():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._zoom_drag_btn.setChecked(True)
gui._on_zoom_dragged(10, 10, 110, 60) # a 100x50 rect
assert gui._zoom_mode not in ("fit",)
assert isinstance(gui._zoom_mode, float)
assert not any(a.isChecked() for a in gui._zoom_buttons)
assert not gui._zoom_drag_btn.isChecked()
assert gui._pending_zoom_center == (60.0, 35.0) # center of the dragged rect
finally:
gui.stop()
def test_zoom_dragged_ignores_accidental_click():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._on_zoom_dragged(10, 10, 11, 10) # 1x0 -- not a real drag
assert gui._zoom_mode == "fit"
assert gui._pending_zoom_center is None
finally:
gui.stop()
def test_apply_pending_zoom_center_clears_itself():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui.window.show()
gui._pending_zoom_center = (42.0, 17.0)
gui._apply_pending_zoom_center(1.0)
assert gui._pending_zoom_center is None
finally:
gui.stop()
# -- settings dock fold/unfold restores window width ---------------------
def test_hiding_settings_dock_shrinks_window_and_showing_restores_it():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui.window.show()
width_with_settings = gui.window.width()
gui._settings_action.setChecked(False)
assert gui._settings_dock.isVisible() is False
assert gui.window.width() < width_with_settings
assert gui._window_width_before_settings_hidden == width_with_settings
gui._settings_action.setChecked(True)
assert gui._settings_dock.isVisible() is True
assert gui.window.width() == width_with_settings
assert gui._window_width_before_settings_hidden is None
finally:
gui.stop()
# -- histogram/colorbar drag-a-region to set both color limits at once ---
class _FakeMouseEvent:
def __init__(self, x, y):
self._pos = QtCore.QPoint(x, y)
def pos(self):
return self._pos
def globalPos(self):
return self._pos
def _build_histogram():
hist = _HistogramColorbar()
hist.resize(90, 200)
arr = np.linspace(0, 100, 100).reshape(10, 10)
hist.set_data(arr, vmin=None, vmax=None, colormap="gray")
return hist
def test_histogram_region_drag_sets_vmin_and_vmax():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
hist = _build_histogram()
received = []
hist.levels_changed.connect(lambda vmin, vmax: received.append((vmin, vmax)))
# y=80/y=140 are far from either handle (at y=0 and y=200 here -- see
# class docstring/value_to_y), so this starts a region drag, not a
# single-handle drag
hist.mousePressEvent(_FakeMouseEvent(50, 80))
assert hist._region_drag_start == 80
hist.mouseMoveEvent(_FakeMouseEvent(50, 140))
assert hist._region_drag_current == 140
hist.mouseReleaseEvent(_FakeMouseEvent(50, 140))
assert hist._region_drag_start is None
assert hist._region_drag_current is None
assert len(received) == 1
vmin, vmax = received[0]
assert vmin < vmax
assert (hist._vmin, hist._vmax) == (vmin, vmax)
def test_histogram_region_drag_ignores_a_stray_click():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
hist = _build_histogram()
received = []
hist.levels_changed.connect(lambda vmin, vmax: received.append((vmin, vmax)))
original = (hist._vmin, hist._vmax)
hist.mousePressEvent(_FakeMouseEvent(50, 80))
hist.mouseReleaseEvent(_FakeMouseEvent(50, 81)) # 1px -- not a real drag
assert received == []
assert (hist._vmin, hist._vmax) == original
def test_histogram_single_handle_drag_still_works_unchanged():
"""Regression check: the pre-existing single-handle drag (press exactly
on a handle) must still behave as before, unaffected by the new
region-drag gesture living in the same mouse handlers."""
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
hist = _build_histogram()
received = []
hist.levels_changed.connect(lambda vmin, vmax: received.append((vmin, vmax)))
max_handle_y = hist._y(hist._vmax) # 0 for a freshly-built histogram
hist.mousePressEvent(_FakeMouseEvent(50, max_handle_y))
assert hist._dragging == "max"
hist.mouseMoveEvent(_FakeMouseEvent(50, max_handle_y + 20))
hist.mouseReleaseEvent(_FakeMouseEvent(50, max_handle_y + 20))
assert hist._dragging is None
assert received # levels_changed fired live during the drag
assert hist._vmax < 100.0 # moved down from the original data max
# -- Calibrate menu (numeric / 2-line / set-center-only) + Measure tool ----
# kind="demo" throughout (no cam_server/network needed for the viewer
# itself); calibration persistence is exercised against
# eco.devices_general.cameras_swissfel.get/set_camera_calibration mocked at
# that module level (where CamServerStreamQt's background-thread closures
# import them from) -- real end-to-end round-tripping through those
# functions is covered directly in tests/test_cameras_swissfel.py.
def _wait_until(predicate, timeout=2.0):
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def test_exclusive_tools_mutual_exclusion_holds_at_full_tool_count():
"""Regression check at the *current* tool count (6): this exact kind
of cascade-ordering bug (an newly-checked tool's own mode getting
stomped back to None by a sibling's forced uncheck) bit the 3-tool
case once already -- see _on_tool_toggled's own docstring."""
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._cal_unit = "um" # skip calibrate_line's one-time unit-name prompt
buttons = {
"roi": gui._roi_btn,
"zoom": gui._zoom_drag_btn,
"marker": gui._marker_btn,
"calibrate_line": gui._calibrate_line_btn,
"calibrate_center": gui._calibrate_center_btn,
"measure": gui._measure_btn,
}
for mode, button in buttons.items():
button.setChecked(True)
assert gui._label.interaction_mode == mode, mode
for other_mode, other_button in buttons.items():
if other_mode != mode:
assert not other_button.isChecked(), (mode, other_mode)
button.setChecked(False)
assert gui._label.interaction_mode is None
finally:
gui.stop()
def test_measure_tool_drag_sets_measurement_and_stays_active():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._measure_btn.setChecked(True)
gui._on_line_dragged(10, 10, 110, 10) # 100px horizontal line
assert gui._measurement == (10, 10, 110, 10)
assert gui._measure_btn.isChecked() # stays active -- unlike ROI/zoom's one-shot tools
assert gui._measurement_label() == "100 px"
finally:
gui.stop()
def test_measure_tool_ignores_accidental_click():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._measure_btn.setChecked(True)
gui._on_line_dragged(10, 10, 11, 10) # 1px -- not a real drag
assert gui._measurement is None
finally:
gui.stop()
def test_measurement_label_uses_calibrated_units_and_per_axis_scale():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._cal_unit = "um"
gui._cal_scale_x = 2.0
gui._cal_scale_y = 3.0
gui._measurement = (0, 0, 10, 0) # 10px purely along X
assert gui._measurement_label() == "20 um"
finally:
gui.stop()
def test_clear_measurement_action():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._measurement = (0, 0, 10, 0)
gui._on_clear_measurement()
assert gui._measurement is None
finally:
gui.stop()
def test_current_reticle_center_defaults_to_image_center_of_last_frame():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
assert gui._reticle_center is None
gui._last_display_array = np.zeros((100, 200))
assert gui._current_reticle_center() == (100.0, 50.0)
finally:
gui.stop()
def test_current_reticle_center_uses_explicit_value_when_set():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._reticle_center = (12.0, 34.0)
gui._last_display_array = np.zeros((100, 200)) # must be ignored once explicit
assert gui._current_reticle_center() == (12.0, 34.0)
finally:
gui.stop()
def test_calibrate_center_click_local_only_moves_reticle():
"""No cam= -- nothing to persist to, but the reticle still moves
locally and the tool deactivates after one click (unlike Measure)."""
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._calibrate_center_btn.setChecked(True)
gui._on_calibrate_center_clicked(123, 45)
assert gui._reticle_center == (123, 45)
assert not gui._calibrate_center_btn.isChecked()
finally:
gui.stop()
def test_calibrate_center_click_with_camera_persists_position_only(monkeypatch):
calls = []
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel.set_camera_calibration",
lambda cam, x, y, x_um=None, y_um=None: calls.append((cam, x, y, x_um, y_um)),
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = object()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window()
gui._on_calibrate_center_clicked(50, 60)
assert _wait_until(lambda: len(calls) == 1)
cam, x, y, x_um, y_um = calls[0]
assert cam is fake_cam
assert (x, y) == (50, 60)
assert x_um is None and y_um is None # position only -- existing scale kept server-side
assert gui._reticle_center == (50, 60)
finally:
gui.stop()
def test_calibrate_line_tool_prompts_for_unit_name_on_first_use(monkeypatch):
monkeypatch.setattr(
QtWidgets.QInputDialog, "getText", staticmethod(lambda *a, **k: ("mm", True))
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
assert gui._cal_unit == "px"
gui._calibrate_line_btn.setChecked(True)
assert gui._cal_unit == "mm"
assert gui._calibrate_line_btn.isChecked() # tool actually starts
finally:
gui.stop()
def test_calibrate_line_tool_cancelled_unit_prompt_aborts_the_tool(monkeypatch):
monkeypatch.setattr(
QtWidgets.QInputDialog, "getText", staticmethod(lambda *a, **k: ("", False))
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._calibrate_line_btn.setChecked(True)
assert not gui._calibrate_line_btn.isChecked()
assert gui._cal_unit == "px" # unchanged
finally:
gui.stop()
def test_calibrate_line_two_stage_flow_computes_per_axis_scale(monkeypatch):
# X stage: 100 (unit) over a 50px line -> 2 unit/px; Y stage: 30 over 10px -> 3 unit/px
responses = iter([(100.0, True), (30.0, True)])
monkeypatch.setattr(
QtWidgets.QInputDialog, "getDouble",
staticmethod(lambda *a, **k: next(responses)),
)
calls = []
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel.set_camera_calibration",
lambda cam, x, y, x_um=None, y_um=None: calls.append((cam, x, y, x_um, y_um)),
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = object()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window()
gui._cal_unit = "um" # skip the unit-name prompt for this test
gui._calibrate_line_btn.setChecked(True)
gui._on_line_dragged(0, 0, 50, 0) # X stage: 50px horizontal
assert gui._calib_line_stage == "y"
assert gui._calibrate_line_btn.isChecked() # stays active for the 2nd line
gui._on_line_dragged(0, 0, 0, 10) # Y stage: 10px vertical
assert not gui._calibrate_line_btn.isChecked() # done
assert gui._calib_line_stage is None
assert _wait_until(lambda: len(calls) == 1)
cam, x, y, x_um, y_um = calls[0]
assert cam is fake_cam
assert (x_um, y_um) == pytest.approx((2.0, 3.0))
finally:
gui.stop()
def test_calibrate_line_cancel_mid_stage_resets_state(monkeypatch):
monkeypatch.setattr(
QtWidgets.QInputDialog, "getDouble", staticmethod(lambda *a, **k: (0.0, False))
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._cal_unit = "um"
gui._calibrate_line_btn.setChecked(True)
gui._on_line_dragged(0, 0, 50, 0) # X stage, but the dialog is cancelled
assert not gui._calibrate_line_btn.isChecked()
assert gui._calib_line_stage is None
assert gui._calib_line_x_um_per_px is None
finally:
gui.stop()
def test_switching_tool_mid_calibration_line_resets_staged_state(monkeypatch):
monkeypatch.setattr(
QtWidgets.QInputDialog, "getDouble", staticmethod(lambda *a, **k: (100.0, True))
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False)
try:
gui._build_window()
gui._cal_unit = "um"
gui._calibrate_line_btn.setChecked(True)
gui._on_line_dragged(0, 0, 50, 0) # completes the X stage
assert gui._calib_line_stage == "y"
gui._roi_btn.setChecked(True) # switch away before finishing the Y line
assert not gui._calibrate_line_btn.isChecked()
assert gui._calib_line_stage is None
assert gui._calib_line_x_um_per_px is None
assert gui._label.interaction_mode == "roi"
finally:
gui.stop()
def test_calibrate_numeric_updates_unit_and_persists(monkeypatch):
calls = []
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel.set_camera_calibration",
lambda cam, x, y, x_um=None, y_um=None: calls.append((cam, x, y, x_um, y_um)),
)
class FakeDialog:
def __init__(self, *a, **k):
self.scale_x = 7.0
self.scale_y = 8.0
self.unit = "mm"
def exec_(self):
return QtWidgets.QDialog.Accepted
monkeypatch.setattr("eco.widgets.camserver_stream_qt._CalibrationDialog", FakeDialog)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = object()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window()
gui._on_calibrate_numeric()
assert gui._cal_unit == "mm"
assert _wait_until(lambda: len(calls) == 1)
cam, x, y, x_um, y_um = calls[0]
assert cam is fake_cam
assert (x_um, y_um) == (7.0, 8.0)
finally:
gui.stop()
def test_load_camera_calibration_populates_state_on_open(monkeypatch):
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel.get_camera_calibration",
lambda cam: (55.0, 66.0, 2.5, 3.5),
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = object()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window() # calls _load_camera_calibration internally
assert _wait_until(lambda: gui._reticle_center == (55.0, 66.0))
assert gui._cal_scale_x == pytest.approx(2.5)
assert gui._cal_scale_y == pytest.approx(3.5)
assert gui._cal_unit == "um" # promoted from the "px" default
finally:
gui.stop()
def test_load_camera_calibration_leaves_defaults_when_none_exists(monkeypatch):
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel.get_camera_calibration", lambda cam: None
)
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
fake_cam = object()
gui = CamServerStreamQt("demo", kind="demo", cam=fake_cam, auto_start=False)
try:
gui._build_window()
time.sleep(0.2) # give the background thread a moment to run and return early
assert gui._reticle_center is None
assert gui._cal_unit == "px"
finally:
gui.stop()
def test_load_camera_calibration_skipped_when_no_camera():
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
gui = CamServerStreamQt("demo", kind="demo", auto_start=False) # no cam=
try:
gui._build_window()
assert gui._reticle_center is None
finally:
gui.stop()