recspace geom, camera fixes

This commit is contained in:
2026-09-16 07:16:13 +02:00
parent e2e17c38eb
commit ad8d30487d
5 changed files with 2440 additions and 51 deletions
+85 -49
View File
@@ -305,23 +305,42 @@ class CamserverConfig(Assembly):
def _spawn_separate_process_viewer(
pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None
pvname,
name=None,
cam_class=None,
pipeline_url=None,
rate_hz=10.0,
theme=None,
dockable=True,
):
"""Launch eco.widgets.camserver_stream_qt's own CLI as an independent
OS process and embed its window into a local wrapper via
eco.widgets.subprocess_embed -- the same spawn/WINID-handshake/embed
mechanism eco.widgets.camserver_panel_qt already uses per-viewer (see
that module and eco.widgets.subprocess_embed.spawn_and_embed), so the
resulting window can be docked into the shared EcoDesktopApp workbench
(see CameraBasler._default_dock_in) instead of popping untethered.
OS process. dockable=True (the default): embed its window into a local
wrapper via eco.widgets.subprocess_embed's WINID-handshake/embed
mechanism (the same one eco.widgets.camserver_panel_qt uses per-viewer,
see that module and eco.widgets.subprocess_embed.spawn_and_embed), so
the result can be docked into the shared EcoDesktopApp workbench (see
CameraBasler._default_dock_in) instead of popping untethered.
Why a separate process at all: Qt's event loop is only pumped between
IPython prompts (via the terminal's GUI-integration hook, same as
eco.utilities.strip_plot's own module docstring explains for exactly
this reason). A viewer built in-process therefore freezes -- both its
live image updates and the whole window's responsiveness -- for the
duration of any single blocking statement in this session (a
synchronous motor move, a long scan, ...). A separate-process viewer
dockable=False: skip that embedding entirely -- the subprocess's window
is a plain, independent, WM-managed top-level window (see
eco.widgets.subprocess_embed.DetachedProcessWindow), never dockable
(widget(dock_in=True) then silently no-ops, per
EcoDesktopApp._dock_widget_object), but immune to embedding's own
fragility: QWindow.fromWinId()/createWindowContainer()-based reparenting
is unreliable on some display setups -- confirmed live 2026-09-14 under
an SSH-X11-forwarded session ultimately backed by XWayland on the
client side (content drifting outside its container, not tracking
resizes, not receiving clicks) -- see the "Camera viewer XWayland
embedding" project memory. Prefer this whenever you don't need docking
and have seen that class of glitch.
Why a separate process at all (either way): Qt's event loop is only
pumped between IPython prompts (via the terminal's GUI-integration
hook, same as eco.utilities.strip_plot's own module docstring explains
for exactly this reason). A viewer built in-process therefore freezes
-- both its live image updates and the whole window's responsiveness
-- for the duration of any single blocking statement in this session
(a synchronous motor move, a long scan, ...). A separate-process viewer
has its own independent event loop untouched by that.
`name`: the eco device's own alias name (e.g. "bernina.cam1", NOT the
@@ -344,16 +363,18 @@ def _spawn_separate_process_viewer(
use `separate_process=False` (in-process, `cam=self` -- e.g.
camera.screenpanel_ana for intensity computation) instead.
Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow (the
.window/.stop() wrapper convention every eco Qt widget follows -- see
EcoDesktopApp._dock_widget_object) -- NOT a bare subprocess.Popen like
before, since it must be dockable via Assembly.widget()'s dock_in
machinery (see CameraBasler/CameraPCO._default_dock_in)."""
Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow or (when
dockable=False) .DetachedProcessWindow -- both follow the .window/
.stop() wrapper convention every eco Qt widget follows (see
EcoDesktopApp._dock_widget_object), NOT a bare subprocess.Popen like
before this pair existed."""
import sys
from eco.widgets.subprocess_embed import EmbeddedProcessWindow
from eco.widgets.subprocess_embed import DetachedProcessWindow, EmbeddedProcessWindow
argv = [pvname, "--kind", "camera_pipeline", "--embed", "--rate", str(rate_hz)]
argv = [pvname, "--kind", "camera_pipeline", "--rate", str(rate_hz)]
if dockable:
argv += ["--embed"]
if pipeline_url:
argv += ["--pipeline-url", pipeline_url]
if theme:
@@ -364,7 +385,9 @@ def _spawn_separate_process_viewer(
argv += ["--cam-class", cam_class]
cmd = [sys.executable, "-m", "eco.widgets.camserver_stream_qt", *argv]
return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}")
if dockable:
return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}")
return DetachedProcessWindow(cmd)
def get_camera_calibration(camera):
@@ -867,35 +890,45 @@ class CameraBasler(Assembly):
theme=None,
auto_start=True,
separate_process=True,
dockable=True,
):
"""Open the live cam_server "screen panel" viewer for this camera's
own default processing pipeline -- the pipeline name is resolved
automatically from self.pvname (no need to know or guess a
pipeline/instance name), mirroring pshell's own screen panel (see
eco.widgets.camserver_stream_qt.resolve_camera_pipeline for the
exact "{camera}_sp" naming convention and auto-create-if-missing
behavior this reuses). The window's "Camera Settings" button opens
widget(normal=True) (the normal property-grid display) -- mirrors
eco.devices_general.cameras_ptz.AxisPTZ._widget_viewer().
"""Open the live cam_server viewer for this camera's own default
processing pipeline (resolved automatically from self.pvname).
separate_process=True (the default): run the viewer in its own OS
process instead of this one -- immune to this session blocking on
something (a synchronous motor move, a long scan, ...) -- pops as
its own standalone window by default, but can be docked into the
shared EcoDesktopApp workbench instead via widget(dock_in=True)
(see _default_dock_in/Assembly._maybe_dock), titled by this
camera's own eco name, and with its own independently-built camera
object (talking directly to EPICS/cam_server) behind its "Camera Settings" button
-- see _spawn_separate_process_viewer for the full trade-off and
the deferred-IPC TODO. Returns an
eco.widgets.subprocess_embed.EmbeddedProcessWindow rather than a
CamServerStreamQt instance in that case.
separate_process=True (the default): runs in its own OS process, so
it keeps updating/responding even while this session is blocked
(a motor move, a scan, ...). Costs ~10-15s to start (a fresh process
importing eco) and its "Camera Settings" button opens an
independent camera object talking directly to EPICS/cam_server --
not this one, so e.g. camera.screenpanel_ana state set here isn't
reflected there.
separate_process=False: build the viewer in this process instead,
with a direct (not rebuilt) `cam=self` reference -- e.g. for
camera.screenpanel_ana intensity/analysis access tied to this
exact object, at the cost of the viewer freezing for the duration
of any blocking statement in this session."""
dockable=True (the default): standalone window unless you pass
widget(dock_in=True) to dock it into the shared EcoDesktopApp
workbench -- but either way, the window is embedded into its
wrapper via X11 reparenting (QWindow.fromWinId +
createWindowContainer, see eco.widgets.subprocess_embed), which
is known to misbehave on some desktops/remote-display setups
(embedded content drifting outside its container, not tracking
resizes, or not receiving clicks -- confirmed under an
SSH-X11-forwarded session backed by XWayland).
dockable=False: skips that embedding entirely -- a genuine,
independent, WM-managed top-level window that can never be
dockable (widget(dock_in=True) then silently no-ops), but immune
to the reparenting glitches above. Prefer this if you've hit that
class of display bug and don't need docking; separate_process=
False (below) is the other way around it, if you don't need a
separate process either.
separate_process=False: builds the viewer in this process instead --
starts instantly, no subprocess/X11-embedding involved so no risk
of the display glitches above, and `cam=self` is the exact same
object (screenpanel_ana etc. all tied to it). Trade-off: the viewer
freezes (no repaint, no clicks) for the duration of any blocking
statement in this session (a synchronous motor move, a long scan,
...) -- prefer this whenever you're just looking at the image and
not about to run something long alongside it."""
alias = getattr(self, "alias", None)
name = alias.get_full_name() if alias is not None else None
@@ -907,6 +940,7 @@ class CameraBasler(Assembly):
pipeline_url=pipeline_url,
rate_hz=rate_hz,
theme=theme,
dockable=dockable,
)
from ..widgets.camserver_stream_qt import make_camserver_stream_qt
@@ -1138,10 +1172,11 @@ class CameraPCO(Assembly):
theme=None,
auto_start=True,
separate_process=True,
dockable=True,
):
"""Open the live cam_server "screen panel" viewer for this camera's
own default processing pipeline -- see CameraBasler._widget_viewer(),
which this mirrors (separate_process included)."""
which this mirrors (separate_process/dockable included)."""
alias = getattr(self, "alias", None)
name = alias.get_full_name() if alias is not None else None
@@ -1153,6 +1188,7 @@ class CameraPCO(Assembly):
pipeline_url=pipeline_url,
rate_hz=rate_hz,
theme=theme,
dockable=dockable,
)
from ..widgets.camserver_stream_qt import make_camserver_stream_qt
File diff suppressed because one or more lines are too long
+138
View File
@@ -9,6 +9,11 @@ import pandas as pd
import numpy as np
from datetime import datetime
import os
import json
import html as html_lib
import tempfile
import webbrowser
from pathlib import Path
from PIL import Image
from scipy.spatial.transform import Rotation
@@ -27,6 +32,72 @@ from epics import PV
logger = logging.getLogger(__name__)
# Vendored from swissfel/bernina-scripts apps/dev/diffractometer_3d.html: a
# self-contained Three.js reciprocal-space viewer that needs no server, only
# a JSON payload (angles, UB matrix, k-magnitude, surface normal) written
# into a hidden element it polls every 150ms.
_GEOM_WIDGET_HTML_PATH = Path(__file__).parent / "diffractometer_3d.html"
def _build_geom_widget_html(entries):
"""Wrap the vendored 3D viewer with a sidebar that lets you click through
``entries`` (each ``{"label": str, "payload": dict}``), writing the
clicked entry's payload into the "#diff3d-payload" element the viewer
polls."""
scene_html = _GEOM_WIDGET_HTML_PATH.read_text()
srcdoc = html_lib.escape(scene_html)
entries_json = json.dumps(entries)
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Reciprocal space geometry</title>
<style>
html, body {{ margin: 0; height: 100%; font-family: sans-serif; }}
#layout {{ display: flex; height: 100vh; }}
#sidebar {{ width: 240px; overflow: auto; border-right: 1px solid #ccc; padding: 8px; box-sizing: border-box; }}
#sidebar h3 {{ margin: 4px 0 8px; font-size: 13px; }}
button.refl {{
display: block; width: 100%; text-align: left; margin: 2px 0;
padding: 5px 6px; font-size: 12px; border: 1px solid #ccc; background: #fff;
cursor: pointer;
}}
button.refl.active {{ background: #cfe8fb; border-color: #7ab8e6; }}
#frame-wrap {{ flex: 1; min-width: 0; }}
iframe {{ width: 100%; height: 100%; border: 0; }}
</style>
</head>
<body>
<div id="layout">
<div id="sidebar">
<h3>Reflections</h3>
<div id="refl-list"></div>
</div>
<div id="frame-wrap">
<iframe id="diff3d" srcdoc="{srcdoc}"></iframe>
</div>
</div>
<span id="diff3d-payload" style="display:none"></span>
<script>
const ENTRIES = {entries_json};
function show(i) {{
document.getElementById('diff3d-payload').textContent = JSON.stringify(ENTRIES[i].payload);
document.querySelectorAll('#refl-list .refl').forEach((b, j) => b.classList.toggle('active', j === i));
}}
const list = document.getElementById('refl-list');
ENTRIES.forEach((entry, i) => {{
const b = document.createElement('button');
b.className = 'refl';
b.textContent = entry.label;
b.onclick = () => show(i);
list.appendChild(b);
}});
if (ENTRIES.length) show(0);
</script>
</body>
</html>
"""
class Diffractometer_Dummy(Assembly):
def __init__(self, *args, name=None, **kwargs):
@@ -1024,6 +1095,73 @@ class DiffGeometryYou(Assembly):
)
return solution
def _geom_entry(self, label, mu, delta, gamma, eta, chi, phi, energy=None, hkl=None):
"""Build one clickable entry (angles + UB + surface normal, as the
vendored 3D viewer expects) for the given diffractometer position."""
if energy is None:
energy = self.get_energy()
if hkl is None:
hkl = self.calc_hkl(
mu=mu, delta=delta, gamma=gamma, eta=eta, chi=chi, phi=phi, energy=energy
)
if hkl is not None:
label = f"{label} ({hkl[0]:.3g}, {hkl[1]:.3g}, {hkl[2]:.3g})"
lam = self.en2lam(energy)
n_phi = np.asarray(self.ubcalc.n_phi, dtype=float).ravel()
norm = np.linalg.norm(n_phi)
return {
"label": label,
"payload": {
"angles": {
"mu": mu,
"eta": eta,
"chi": chi,
"phi": phi,
"delta": delta,
"gamma": gamma,
},
"kMag": 2 * np.pi / lam,
"ub": np.asarray(self.ubcalc.UB, dtype=float).tolist(),
"nPhi": (n_phi / norm).tolist() if norm > 0 else [0.0, 0.0, 1.0],
},
}
def widget_geom(self):
"""Open a browser tab visualizing this crystal's reciprocal-space
geometry: the current diffractometer position plus every stored
reference reflection, clickable in a sidebar.
Reuses the 3D viewer from swissfel/bernina-scripts
apps/dev/diffractometer_3d.html unmodified - only the UB matrix,
current/stored angles and energy are fed in, so none of that
notebook's manual unit-cell/orientation input fields are needed here;
this crystal's own UB calculation already has them.
"""
self.to_diffcalc()
mu, delta, gamma, eta, chi, phi = self.get_diffractometer_angles()
entries = [self._geom_entry("current", mu, delta, gamma, eta, chi, phi)]
for i, refl in enumerate(self.reflections()):
mu, delta, gamma, eta, chi, phi = refl["position"]
tag = refl.get("tag") or f"#{i}"
entries.append(
self._geom_entry(
str(tag),
mu,
delta,
gamma,
eta,
chi,
phi,
energy=refl["energy"],
hkl=refl["hkl"],
)
)
path = Path(tempfile.gettempdir()) / f"eco_widget_geom_{self.name}.html"
path.write_text(_build_geom_widget_html(entries))
webbrowser.open(f"file://{path}")
print(f"Opened reciprocal-space geometry view: {path}")
return path
def calc_angles_unique(self, h=None, k=None, l=None, energy=None):
"""calculate unique solution of diffractometer angles for a given h,k,l and energy in eV.
If any of the h, k, l are not given, their current value is used instead.
+57
View File
@@ -178,6 +178,63 @@ def spawn_and_embed(target, cmd, env=None, timeout_s=EMBED_TIMEOUT_S, parent=Non
return process
class DetachedProcessWindow:
""".window/.stop()-shaped wrapper (same convention as
EmbeddedProcessWindow) around a viewer subprocess that is deliberately
NOT embedded anywhere -- its window is a plain, independent, fully
WM-managed top-level window, exactly as if the CLI had been run by
hand. `.window` is always None: there is nothing local to reparent or
dock, which is the point.
Exists to route around QWindow.fromWinId()/createWindowContainer()-
based embedding (see spawn_and_embed/EmbeddedProcessWindow) being
unreliable on some display setups -- XWayland in particular (a Wayland
compositor's X11-compat layer only partially emulates the
XReparentWindow-based trick that embedding depends on), confirmed live
2026-09-14: embedded content drifting outside its container, not
tracking resizes, or not receiving clicks at all. A plain top-level
window never gets reparented in the first place, so it's immune to
that whole class of bug -- at the cost of never being dockable into an
EcoDesktopApp workbench (EcoDesktopApp._dock_widget_object checks for
a real `.window` QWidget and is a documented no-op otherwise, so
`widget(dock_in=True)` on one of these silently just leaves it
standalone rather than erroring)."""
window = None
def __init__(self, cmd, env=None):
# QProcess's signals (on_stderr below) are delivered through Qt's
# event loop like any other -- needs a QApplication to exist, same
# requirement as EmbeddedProcessWindow, even though this class
# never builds a QWidget of its own.
global _app_ref
if QtWidgets.QApplication.instance() is None:
_app_ref = QtWidgets.QApplication([])
self.process = QtCore.QProcess()
self.process.setProcessChannelMode(QtCore.QProcess.SeparateChannels)
def on_stderr():
# Same surfacing as spawn_and_embed's on_stderr -- a crash
# before/without ever showing anything would otherwise be a
# silent disappearance.
chunk = bytes(self.process.readAllStandardError()).decode(errors="ignore")
if chunk:
sys.stderr.write(f"[subprocess_embed] viewer stderr: {chunk}")
sys.stderr.flush()
self.process.readyReadStandardError.connect(on_stderr)
self.process.setProcessEnvironment(env if env is not None else child_process_environment())
self.process.start(cmd[0], cmd[1:])
def stop(self):
if self.process is not None and self.process.state() != QtCore.QProcess.NotRunning:
self.process.terminate()
if not self.process.waitForFinished(2000):
self.process.kill()
self.process.waitForFinished(2000)
class EmbeddedProcessWindow:
""".window/.stop()-shaped wrapper (eco's standard Qt-widget-wrapper
convention -- DisplayQt/AxisPTZStreamQt/CamServerStreamQt/...) around
+51 -2
View File
@@ -148,13 +148,17 @@ def test_camera_basler_separate_process_is_the_default(monkeypatch):
def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeypatch):
calls = {}
def fake_spawn(pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None):
def fake_spawn(
pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None,
dockable=True,
):
calls["pvname"] = pvname
calls["name"] = name
calls["cam_class"] = cam_class
calls["pipeline_url"] = pipeline_url
calls["rate_hz"] = rate_hz
calls["theme"] = theme
calls["dockable"] = dockable
return "the subprocess handle"
monkeypatch.setattr(
@@ -176,10 +180,28 @@ def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeyp
# and_cam_class below for the real-object case)
assert calls == {
"pvname": BASLER_PVNAME, "name": None, "cam_class": None,
"pipeline_url": None, "rate_hz": 25.0, "theme": "dark",
"pipeline_url": None, "rate_hz": 25.0, "theme": "dark", "dockable": True,
}
def test_camera_basler_viewer_dockable_false_is_passed_through(monkeypatch):
"""widget(dockable=False) must reach _spawn_separate_process_viewer so
it can skip the X11-embedding/dock machinery entirely (see
eco.widgets.subprocess_embed.DetachedProcessWindow) -- not silently get
dropped."""
calls = {}
monkeypatch.setattr(
"eco.devices_general.cameras_swissfel._spawn_separate_process_viewer",
lambda pvname, **kwargs: calls.update(pvname=pvname, **kwargs) or "the subprocess handle",
)
fake_self = _FakeCameraSelf(BASLER_PVNAME)
result = CameraBasler._widget_viewer(fake_self, dockable=False)
assert result == "the subprocess handle"
assert calls["dockable"] is False
def test_camera_basler_viewer_separate_process_passes_name_and_cam_class(monkeypatch):
calls = {}
monkeypatch.setattr(
@@ -263,6 +285,33 @@ def test_spawn_separate_process_viewer_builds_the_expected_command_line(monkeypa
assert captured["title"] == "cam_server stream - bernina.cam1"
def test_spawn_separate_process_viewer_dockable_false_skips_embed_flag(monkeypatch):
"""dockable=False must route through DetachedProcessWindow instead of
EmbeddedProcessWindow, and must NOT pass --embed -- the whole point is
the subprocess never becomes a reparenting target (see
eco.widgets.subprocess_embed.DetachedProcessWindow's docstring)."""
from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer
captured = {}
class _FakeDetachedProcessWindow:
def __init__(self, cmd):
captured["cmd"] = cmd
def fail_if_embedded(*a, **k):
raise AssertionError("dockable=False must not build an EmbeddedProcessWindow")
monkeypatch.setattr(
"eco.widgets.subprocess_embed.DetachedProcessWindow", _FakeDetachedProcessWindow
)
monkeypatch.setattr("eco.widgets.subprocess_embed.EmbeddedProcessWindow", fail_if_embedded)
result = _spawn_separate_process_viewer(BASLER_PVNAME, dockable=False)
assert isinstance(result, _FakeDetachedProcessWindow)
assert "--embed" not in captured["cmd"]
def test_spawn_separate_process_viewer_omits_eco_name_and_cam_class_when_not_given(monkeypatch):
from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer