Instrument View#
Overview#
The Instrument View draws the geometry of an instrument, coloured by the data recorded in the corresponding workspace, and lets the user select detectors, plot their spectra, overlay peaks and build masks, regions of interest and detector groupings.
It is a pure-Python package built on PyVista and VTK, and is
completely separate from the OpenGL widget described in Legacy Instrument Viewer Widget. The two ship
side by side: since v7.0.0 Show Instrument opens this one, and Show Instrument (Legacy)
opens the legacy widget.
The user documentation is at Instrument Viewer Widget.
Where the code lives#
The package is qt/python/instrumentview, and is registered from qt/python/CMakeLists.txt
with:
add_python_package(instrumentview)
add_dependencies(instrumentview mantidqt)
Workbench depends on it, through install_requires in
qt/applications/workbench/setup.py and add_dependencies(workbench ... instrumentview).
There is no separate conda recipe; the package is installed as part of the mantidworkbench
build, and its runtime dependencies pyvista, pyvistaqt and superqt are pinned in
conda/recipes/conda_build_config.yaml.
Module |
Responsibility |
|---|---|
|
|
|
Event handling, renderer and interactor selection |
|
All workspace-side logic; no Qt |
|
Building the detector meshes and the picking callbacks |
|
2D projections of the detector positions |
|
VTK mouse interaction styles |
|
The ROI and mask selection shapes, and the chart overlay they are drawn on |
|
The instrument component tree |
|
Reading and grouping peaks from a |
|
Reacting to workspaces being added, renamed or deleted |
|
A cut-down view for Jupyter notebooks |
|
Standalone and command line entry points |
|
Specialisations embedded in other interfaces |
Architecture#
The interface is Model/View/Presenter, as described in MVP Design:
FullInstrumentViewWindow (QMainWindow)
└── FullInstrumentViewView ──────────── FullInstrumentViewPresenter ──── FullInstrumentViewModel
│ widgets, plotter, line plot on_* handlers, threading workspace, detectors,
│ masks, peaks
├── ShapeOverlayManager │
│ └── SelectionShape ├── InstrumentRenderer
├── ComponentTreeView │ ├── PointCloudRenderer
│ └── ComponentTreePresenter │ ├── ShapeRenderer
│ └── ComponentTreeModel │ └── SideBySideShapeRenderer
└── BackgroundPlotter (pyvistaqt) ├── InteractorStyles
├── Projection
└── InstrumentViewADSObserver
Note that FullInstrumentViewWindow.py contains two classes. FullInstrumentViewWindow is a
thin QMainWindow that sizes and centres itself and holds FullInstrumentViewView as its
central widget; all of the interesting view code is in the latter.
The three parts are assembled by the caller rather than by a factory, identically in
InstrumentView.py and in Workbench
(qt/applications/workbench/workbench/plugins/workspacewidget.py):
model = FullInstrumentViewModel(ws)
window = FullInstrumentViewWindow()
FullInstrumentViewPresenter(window.get_instrument_view_widget(), model)
Connecting the view to the presenter#
There are no custom Qt signals. Wiring happens in two steps from
FullInstrumentViewPresenter.setup():
view.subscribe_presenter(self)stores the presenter on the view, which then pulls the contents of its combo boxes from it (available_unit_options(),count_scale_combo_options(),peaks_workspaces_in_ads()).view.setup_connections_to_presenter()connects the stock Qt widget signals directly to presenter methods:self._projection_combo_box.currentIndexChanged.connect(self._presenter.on_projection_option_changed) self._mask_list.itemChanged.connect(partial(self._presenter.on_list_item_selected, CurrentTab.Masking))
The presenter never touches a widget directly; it calls named methods on the view such as
set_contour_range_limits or show_plot_for_detectors. The model imports no Qt at all.
Threading#
This is the part most likely to catch out a new developer. There is no QThread and no progress
bar. Responsiveness comes from three mechanisms working together.
A worker thread in the presenter. The presenter owns a daemon thread and a Queue. Almost
every on_* handler does nothing but enqueue its _on_* counterpart, so the Qt thread returns
immediately:
self._callback_queue = Queue()
self._callback_thread = Thread(None, self._callback_worker, daemon=True)
self._callback_thread.start()
handle_close() pushes a sentinel object to stop the worker.
Automatic marshalling back onto the Qt thread. FullInstrumentViewView is decorated with
@run_on_qapp_thread() from mantidqt.utils.qt.qappthreadcall, which replaces every public
method with a blocking QAppThreadCall. The worker thread can therefore call view methods
directly. Note that private, underscore-prefixed methods are not wrapped, so a public method
that delegates to a private one is safe, but calling a private method from the worker thread is
not.
VTK work must happen on the Qt thread first. Anything that touches the VTK render window has to
run before the work is queued. on_shape_changed is the pattern to copy:
def on_shape_changed(self):
centres = self._model.transformed_detector_positions
self._view.project_and_cache_detector_points(centres) # VTK, so Qt thread
self._shape_update_generation += 1
self._callback_queue.put((self._on_shape_changed, (centres, self._shape_update_generation)))
The generation counter lets the worker discard superseded updates, which matters when a shape is dragged or the wheel is scrolled and events arrive faster than they can be processed.
The queue also means the state a handler read can have moved on by the time the work runs, so a
handler that acts on the current selection has to snapshot it rather than read it again later.
on_create_item_from_selection_clicked is the pattern to copy:
def on_create_item_from_selection_clicked(self):
selection = self._model.picked_detector_mask
pickable = self._model.is_pickable
point_picks = self._model.point_picked_detectors
tab = self._view.get_current_selected_tab()
self._callback_queue.put((self._on_create_item_from_selection_clicked, (selection, pickable, point_picks, tab)))
The is_pickable mask is carried along because selection is positioned by it, so the worker
can compare and refuse to commit if masking or a component tree selection has swapped detectors in
or out of the pickable set meanwhile. The count would be unchanged, but every entry after the swap
would describe a different detector.
Related helpers are the SuppressRendering context manager, used to batch plotter updates, and
the _skip_if_closing decorator, which guards view methods against calls arriving after
closeEvent.
Rendering#
The plotter is a pyvistaqt.BackgroundPlotter whose app_window is placed in a splitter
alongside a matplotlib canvas for the line plot:
self.main_plotter = BackgroundPlotter(show=False, menu_bar=False, toolbar=False, off_screen=self._off_screen)
renderers/base_renderer.py defines the InstrumentRenderer abstract base class. A renderer
builds two meshes and supplies the picking callback:
the detector mesh, built from the pickable detectors and coloured by counts. It is added with
pickable=Trueand is the only mesh clicks are picked against;the masked mesh, drawn in a flat dark grey with
pickable=False.
Monitors and the sample are not the renderer’s concern: the presenter adds them as separate
point meshes through FullInstrumentViewView.add_rgba_mesh.
The selection is not a mesh of its own either. Each renderer draws it with a magenta marker on a
persistent highlight actor, created once per plotter rebuild by create_picked_highlight_actor
and afterwards only given new data and shown or hidden by update_picked_highlight. The marker is sized in screen pixels rather than
by tinting the detectors, so it stays visible when a large instrument is zoomed out until each
detector covers a pixel or less.
The three implementations correspond to the render modes offered in the GUI:
PointCloudRendererOne point per detector in a single
pv.PolyData, drawn as screen-space spheres. Picking uses avtkPointPicker. The selection is a larger sphere sprite centred on each picked detector, with its middle discarded in a fragment shader so the counts colour still shows through the ring.ShapeRendererDraws real detector geometry. Detectors are grouped by unique shape using
componentInfo.shapeToComponentIndices(), so the geometry is only built once per distinct shape. For each shape it either extracts an optimised four-vertex quad from theShapeInfo, or falls back to the full triangulated mesh fromCSGObject.getMesh(). The instances are then scaled, rotated and translated with vectorised NumPy usingdetectorInfo.allRotations(),allScaleFactors()andallPositions(), and merged into a singlepv.PolyDataso that VTK issues one draw call for the whole instrument. A_cell_to_detectorarray maps VTK cell IDs back to detector indices, for picking, for writing the scalars and for picking out the cells of the selected detectors.The selection is outlined with the silhouette of the picked cells, from a
vtkPolyDataSilhouetteleft in the pipeline because the silhouette changes as the camera moves. Edges would be wrong for anything but a flat quad: a raw cuboid would draw as a wireframe box and a raw cylinder as two cap rings. The outline is also lifted towards the camera by the depth of the picked shapes, or it would be buried in the detectors packed around it. Past_MAX_OUTLINE_CELLSpicked cells, recomputing the silhouette on every camera move gets too slow, so a second actor takes over with one marker point per picked detector.SideBySideShapeRendererSubclasses
ShapeRenderer. It uses nearest-neighbour distances per bank (scipy.spatial.cKDTree) to scale the shapes so that unrolled panels do not overlap, and applies bank rotations only to tube banks.
Switching render mode does not rebuild everything: the presenter keeps the renderer instances alive and only reloads them when the workspace itself changes.
Projections#
Projections/Projection.py is a base class with a self-registering subclass registry, so a new
projection needs no changes to any factory or combo box:
class Projection:
_registry = {}
def __init_subclass__(cls, projection_types=None, **kwargs):
for projection_type, defaults in projection_types.items():
Projection._registry[projection_type] = (cls, defaults)
def __new__(cls, type, **kwargs):
if cls is Projection:
subclass, _ = Projection._registry.get(type)
return super().__new__(subclass)
The ProjectionType enum is used to derive the GUI’s list of projections. If u and v are
the horizontal and vertical axes of the 2D projection, respectively, then the projections are
created as follows.
SphericalProjectionusesu = -atan2(y, x)andv = -acos(v/r).CylindricalProjectionis equal-area:u = -atan2(y, x)andv = z/|r|.SideBySidefinds the flat banks – grid banks throughinstrument.findGridDetectors()and tube banks through the shared C++PanelsSurfaceCalculator– unrolls each into its own panel, and tiles the panels. Bank positions from theside-by-side-view-locationtag in the IDF are honoured where present.
The base class also corrects the seam: it finds the largest gap in the u coordinate and shifts
the range by multiples of u_period so that a bank is not split across the wrap-around point.
project_points() is the public entry point, and is what ShapeRenderer uses to project the
vertices of the detector shapes rather than just their centres.
Projections are cached on the model per (projection type, flip beam) pair. Fitting the
projection to the window is not part of the projection: the presenter computes a scale matrix in
_transform_mesh_to_fill_window() and assigns it to model.transform.
Picking and mouse interaction#
Each renderer supplies get_callback_tied_to_detector_index(plotter, callback, hover), which
wraps a vtkPointPicker or vtkCellPicker and translates a hit into an index into the
pickable detectors. Only the detector mesh has pickable=True, so masked detectors, monitors,
the sample and the selection marker cannot be picked.
InteractorStyles.py holds five VTK styles, and
FullInstrumentViewPresenter._update_interactor_style() chooses between them, in this order:
Condition |
Style |
|---|---|
Not a 2D projection |
|
A shape overlay is active |
|
Hover pick is checked |
|
Rectangle zoom is toggled on |
|
Otherwise |
|
CursorZoomInteractorStyle implements zooming about the cursor in parallel projection: it caches
the world position under the cursor during mouse-move events and, on a wheel event, adjusts the
camera’s parallel_scale and shifts the focal point so that point stays put. It caches a default
camera state, resets to it on right-click, and snaps back to it when zoomed out past it. After each
change it fires camera_changed_callback so that a screen-space shape overlay can re-evaluate
which detectors it covers.
RubberBandZoomInteractorStyle replaces VTK’s default left-button observers so that holding
Shift, Ctrl or Alt picks a detector instead of starting a rubber band.
Shape overlays#
ShapeWidgets.py defines a SelectionShape base class and the circle, rectangle, ellipse,
annulus and hollow rectangle implementations. Shapes work in normalised [0, 1] viewport
coordinates and provide outline_xy, fill_coords, hit_test, apply_resize_delta and
indices_in_shape. hit_test returns "inside", "edge", "inner_edge" or
"handle", which is what decides between moving, resizing and rotating, and which cursor is
shown.
ShapeOverlayManager draws the shape on a transparent pv.Chart2D laid over the render, adds
the VTK mouse observers, projects the 3D detector positions into chart coordinates, and returns the
boolean mask of the detectors the shape covers.
The model#
FullInstrumentViewModel keeps full-length arrays for every detector and a set of boolean masks
over them. Nearly every public property is a slice of one of those arrays:
self._is_valid = (self._is_monitor != "yes") & (self._workspace_indices != -1)
is_pickable = ~self._is_masked & self._is_valid & self._is_selected_in_tree
Understanding which mask a property is sliced by is usually enough to understand the property. Note in particular that indices coming back from a picking callback are indices into the pickable detectors, not into all detectors.
There are two masks describing the selection, and they differ in both length and content. picked_detector_mask has one entry per pickable detector,
matching the meshes and the picking callbacks, and covers everything highlighted – both the
detectors clicked in the projection and those of any ticked Grouping entry, which
apply_detector_items unions together into _detector_is_picked.
point_picked_detectors has one entry per detector, so it indexes the full-length arrays, and
holds only the detectors clicked in the projection. It returns a copy, because the model picks into
its own array in place and a caller holding it as a snapshot would otherwise see it change
underneath them.
Bulk detector data is read with the CreateDetectorTable algorithm rather than a Python loop over detectors, which is much faster for large instruments:
detector_info_table = CreateDetectorTable(
self._workspace, IncludeDetectorPosition=True, OneRowPerDetectorID=True,
StoreInADS=False, EnableLogging=False
)
Integrated counts come from the C++ method
MatrixWorkspace.getIntegratedCountsForWorkspaceIndices, again to avoid a per-spectrum loop. The
line plot is produced with ExtractSpectra, then
ConvertUnits if the plot units differ, then, when summing more than one
spectrum, Rebin onto a common grid followed by
SumSpectra. The rebin is necessary because ragged workspaces cannot be
summed directly.
Component tree#
ComponentTreeModel wraps workspace.componentInfo(). ComponentTreePresenter builds a
QStandardItemModel lazily: a node that has children is given a single child with the text
##placeholder##, which is replaced with the real children the first time the node is expanded.
Without this, building the tree for a large instrument took several seconds.
Selection is propagated as the union of componentsInSubtree() for each selected node. The model
converts those component indices to detector IDs and sets _is_selected_in_tree, so unselected
detectors move into the masked mesh and become unpickable.
Reacting to the ADS#
InstrumentViewADSObserver subclasses AnalysisDataServiceObserver and wraps each of its five
callbacks in a QAppThreadCall. The presenter then queues the real work onto its worker thread:
delete closes the window if it was the displayed workspace, otherwise refreshes the lists.
rename re-points the model at the renamed workspace and rebuilds the component tree.
replace re-renders, or refreshes the peaks, mask or grouping list depending on the type.
add adds new peaks, mask or grouping workspaces to the relevant list.
clear closes the window.
handle_close() deletes the observer explicitly rather than leaving it to the garbage collector,
which would otherwise keep stale references to workspaces alive.
Extending the Instrument View#
- New render mode
Implement
InstrumentRenderer, add the mode string toFullInstrumentViewView._RENDER_MODE_OPTIONSand a branch toFullInstrumentViewPresenter._get_renderer_for_mode. Also override_add_picked_highlight_actorand_build_picked_highlight_mesh: they are not abstract, but the base versions draw nothing, and the marker is the only thing that shows the selection.- New projection
Add a member to
ProjectionTypeand subclassProjectionwithprojection_types={ProjectionType.MY_TYPE: {...}}. The combo box is populated from the enum, so nothing else needs changing.- New selection shape
Subclass
SelectionShapeand add it toFullInstrumentViewView._shape_options.- Reusing the view
Subclass
FullInstrumentViewViewand override_set_layoutsto build a different layout from the same widgets, overriding anyset_*methods that do not apply. This is whatalfviewdoes.- Reusing only the model
Compose
FullInstrumentViewModelwith a renderer and interactor styles directly, withoutFullInstrumentViewPresenter. This is whatisisreflectometrydoes.
Warning
The values of Globals.CurrentTab are the literal tab label strings, and
get_current_selected_tab() converts the label back with CurrentTab(tab_name). Renaming a
tab in the GUI without changing the enum will break grouping and masking silently.
Interfaces that embed the Instrument View#
Both current consumers are C++ interfaces that import the Python presenter, and both use it
unless the QSettings flag InstrumentView/use_legacy_instrument_view is set, exposed in Workbench
under Settings -> General. Mind the sense of that flag: it names the widget it selects, so
its default of false gets this one.
alfview/ALFInstrumentViewPresenterandALFInstrumentViewViewsubclass the full presenter and view. The view replaces the whole layout with a rebin box, a hover-pick button and a rectangle tool, and the presenter forces bank/tube selection, theCylindrical Yprojection and the approximated-shapes render mode. It calls back into C++ by finding a childQObjectnamedALFPythonCallbackRelayand invoking itsnotifymethod. Loaded byqt/scientific_interfaces/Direct/ALFPythonInstrumentView.cpp.isisreflectometry/ReflectometryInstrumentViewViewis not a subclass; it is a minimal widget holding only a plotter, composed withFullInstrumentViewModelandShapeRenderer. Its plotter is created lazily ininitialise()to avoid OpenGL context errors before the widget is embedded, and resize-driven updates are debounced with a timer. It relays selection changes through a childQObjectnamedShapeChangedRelay. Loaded byqt/scientific_interfaces/ISISReflectometry/GUI/Preview/PreviewPythonInstrumentView.cpp.
Testing#
Tests live inside the package, in test subdirectories next to the code they cover. They are
plain unittest test cases:
Model tests use real workspaces from
CreateSampleWorkspace(StoreInADS=False).Presenter tests use a
MagicMockview. Where a mock stands in for the model, spec it against the real class so that a call to a method the model does not have fails the test.View tests use
@start_qapplicationfrommantidqt.utils.qt.testing, patchBackgroundPlotterand the matplotlib canvas, and patchforce_method_calls_to_qapp_threadso that the@run_on_qapp_threaddecorator does not interfere.
Run them with:
ctest -R python.instrumentview
Differences from the legacy widget#
Concern |
Legacy (C++) |
Current (Python) |
|---|---|---|
Graphics |
OpenGL, |
VTK through PyVista |
Picking |
Colour-encoded off-screen render |
VTK pickers plus a cell-to-detector map |
Geometry |
A geometry handler per object |
Detectors grouped by unique shape into one merged mesh |
Projections |
|
The |
Shapes |
|
|
Line plot |
|
Embedded matplotlib canvas with the Mantid toolbar |
Project save |
|
Not implemented |
Settings persistence |
None |
A few |