BioImager  3.9.1
A .NET microscopy imaging library. Supports various microscopes by using imported libraries & GUI automation. Supported libraries include PriorĀ® & ZeissĀ® & all devices supported by Micromanager 2.0 and python-microscope.
Loading...
Searching...
No Matches
gui.py
1#!/usr/bin/env python3
2
3## Copyright (C) 2020 David Miguel Susano Pinto <carandraug@gmail.com>
4##
5## This file is part of Microscope.
6##
7## Microscope is free software: you can redistribute it and/or modify
8## it under the terms of the GNU General Public License as published by
9## the Free Software Foundation, either version 3 of the License, or
10## (at your option) any later version.
11##
12## Microscope is distributed in the hope that it will be useful,
13## but WITHOUT ANY WARRANTY; without even the implied warranty of
14## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15## GNU General Public License for more details.
16##
17## You should have received a copy of the GNU General Public License
18## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
19
20"""Simple GUIs for individual devices.
21
22This is meant as a simple GUIs for help during development. It does
23not aim to be pretty; it aims to be simple, complete, and work on any
24OS and Python without extra work. It is not meant as the basis for a
25full-fledged microscope GUI.
26
27This module requires qtpy which is a requirement for the microscope
28"GUI" extra, i.e., only installed by `pip` if microscope is installed
29with ``pip install microscope[GUI]``.
30
31"""
32
33import argparse
34import logging
35import queue
36import sys
37import threading
38import typing
39
40import numpy
41import Pyro4
42from qtpy import QtCore, QtGui, QtWidgets
43
44import microscope.abc
45
46
47_logger = logging.getLogger(__name__)
48
49
50# We use pickle so we can serialize numpy arrays for camera images and
51# deformable mirrors patterns.
52Pyro4.config.SERIALIZERS_ACCEPTED.add("pickle")
53Pyro4.config.SERIALIZER = "pickle"
54
55
56class DeviceSettingsWidget(QtWidgets.QWidget):
57 """Table of device settings and its values.
58
59 This widget simply shows the available settings on the device and
60 their current value. In the future it may add the possibility to
61 modify them.
62
63 """
64
65 def __init__(self, device: microscope.abc.Device, *args, **kwargs) -> None:
66 super().__init__(*args, **kwargs)
67 self._device = device
68
69 layout = QtWidgets.QFormLayout(self)
70 for key, value in sorted(self._device.get_all_settings().items()):
71 layout.addRow(key, QtWidgets.QLabel(parent=self, text=str(value)))
72 self.setLayout(layout)
73
74
75class ControllerWidget(QtWidgets.QWidget):
76 """Show devices in a controller.
77
78 This widget shows a series of buttons with the name of the
79 multiple devices in a controller. Toggling those buttons displays
80 or hides a widget for that controlled device.
81
82 """
83
84 def __init__(
85 self, device: microscope.abc.Controller, *args, **kwargs
86 ) -> None:
87 super().__init__(*args, **kwargs)
88 self._device = device
89
90 self._button2window: typing.Dict[
91 QtWidgets.QPushButton, typing.Optional[QtWidgets.QMainWindow]
92 ] = {}
93 self._button2name: typing.Dict[QtWidgets.QPushButton, str] = {}
94
95 self._button_grp = QtWidgets.QButtonGroup(self)
96 self._button_grp.setExclusive(False)
97 for name in self._device.devices.keys():
98 button = QtWidgets.QPushButton(name, parent=self)
99 button.setCheckable(True)
100 self._button_grp.addButton(button)
101 self._button2name[button] = name
102 self._button2window[button] = None
103 self._button_grp.buttonToggled.connect(self.toggleDeviceWidget)
104
105 layout = QtWidgets.QVBoxLayout()
106 for button in self._button_grp.buttons():
107 layout.addWidget(button)
108 self.setLayout(layout)
109
110 def toggleDeviceWidget(
111 self, button: QtWidgets.QAbstractButton, checked: bool
112 ) -> None:
113 if checked:
114 device = self._device.devices[self._button2name[button]]
115 widget_cls = _guess_device_widget(device)
116 widget = widget_cls(device)
117 window = MainWindow(widget, parent=self)
118 self._button2window[button] = window
119 window.show()
120 else:
121 window = self._button2window[button]
122 if window is None:
123 _logger.error(
124 "unchecking subdevice button but there's no window"
125 )
126 else:
127 window.close()
128 self._button2window[button] = None
129
130
131class _DataQueue(queue.Queue):
132 @Pyro4.expose
133 def put(self, *args, **kwargs):
134 return super().put(*args, **kwargs)
135
136
137class _Imager(QtCore.QObject):
138 """Helper for CameraWidget handling the internals of the camera trigger."""
139
140 imageAcquired = QtCore.Signal(numpy.ndarray)
141
142 def __init__(self, camera: microscope.abc.Camera) -> None:
143 super().__init__()
144 self._camera = camera
145 self._data_queue = _DataQueue()
146 if isinstance(self._camera, Pyro4.Proxy):
147 pyro_daemon = Pyro4.Daemon()
148 queue_uri = pyro_daemon.register(self._data_queue)
149 self._camera.set_client(queue_uri)
150 data_thread = threading.Thread(
151 target=pyro_daemon.requestLoop, daemon=True
152 )
153 data_thread.start()
154 else:
155 self._device.set_client(self._data_queue)
156 fetch_thread = threading.Thread(target=self.fetchLoop, daemon=True)
157 fetch_thread.start()
158
159 # Depending on the Qt backend this might not get called (seems
160 # to work on PySide2 but not with PyQt5). The device itself
161 # should be removing clients that no longer work anyway.
162 self.destroyed.connect(lambda: self._camera.set_client(None))
163
164 def snap(self) -> None:
165 self._camera.trigger()
166
167 def fetchLoop(self) -> None:
168 while True:
169 # We may be getting images faster than we can display so
170 # only get the last image in the queue and discard the
171 # rest (we could do with a class that only has one item
172 # and putting a new item will discard the previous instead
173 # of blocking/queue).
174 data = self._data_queue.get()
175 while not self._data_queue.empty():
176 data = self._data_queue.get()
177 self.imageAcquired.emit(data)
178
179
180class CameraWidget(QtWidgets.QWidget):
181 """Display camera"""
182
183 def __init__(self, device: microscope.abc.Camera, *args, **kwargs) -> None:
184 super().__init__(*args, **kwargs)
185 self._device = device
186 self._imager = _Imager(self._device)
187 self._imager.imageAcquired.connect(self.displayData)
188
189 self._view = QtWidgets.QLabel(parent=self)
190 self.displayData(
191 numpy.zeros(self._device.get_sensor_shape(), dtype=numpy.uint8)
192 )
193
194 self._enable_check = QtWidgets.QCheckBox("Enabled", parent=self)
195 self._enable_check.stateChanged.connect(self.updateEnableState)
196
197 self._exposure_box = QtWidgets.QDoubleSpinBox(parent=self)
198 self._exposure_box.setSuffix(" sec")
199 self._exposure_box.setSingleStep(0.1)
200 self._exposure_box.valueChanged.connect(self._device.set_exposure_time)
201
202 self._snap_button = QtWidgets.QPushButton("Snap", parent=self)
203 self._snap_button.clicked.connect(self._imager.snap)
204
205 self.updateEnableState()
206
207 layout = QtWidgets.QVBoxLayout()
208 controls_row = QtWidgets.QHBoxLayout()
209 for widget in [
210 self._enable_check,
211 self._exposure_box,
212 self._snap_button,
213 ]:
214 controls_row.addWidget(widget)
215 layout.addLayout(controls_row)
216 layout.addWidget(self._view)
217 self.setLayout(layout)
218
219 def updateEnableState(self) -> None:
220 """Update UI and camera state after enable check box"""
221 if self._enable_check.isChecked():
222 self._device.enable()
223 else:
224 self._device.disable()
225
226 if self._enable_check.isChecked() != self._device.get_is_enabled():
227 self._enable_check.setChecked(self._device.get_is_enabled())
228 _logger.error(
229 "failed to %s camera",
230 "enable" if self._enable_check.isChecked() else "disable",
231 )
232
233 self._snap_button.setEnabled(self._device.get_is_enabled())
234 self._exposure_box.setEnabled(self._device.get_is_enabled())
235
236 def displayData(self, data: numpy.ndarray) -> None:
237 np_to_qt = {
238 numpy.dtype("uint8"): QtGui.QImage.Format_Grayscale8,
239 numpy.dtype("uint16"): QtGui.QImage.Format_Grayscale16,
240 }
241 qt_img = QtGui.QImage(
242 data.tobytes(), *data.shape, np_to_qt[data.dtype]
243 )
244 self._view.setPixmap(QtGui.QPixmap.fromImage(qt_img))
245
246
247class DeformableMirrorWidget(QtWidgets.QWidget):
248 """Display a slider for each actuator.
249
250 Constructing this widget will set all actuators to their mid-point
251 since the actuators position are not queryable.. The reset button
252 does this too, i.e., it sets all actuators to their mid-point.
253 """
254
255 def __init__(
256 self, device: microscope.abc.DeformableMirror, *args, **kwargs
257 ) -> None:
258 super().__init__(*args, **kwargs)
259 self._device = device
260
261 self._pattern = numpy.ndarray(shape=(self._device.n_actuators))
262 self._actuators: typing.List[QtWidgets.QSlider] = []
263 for i in range(self._device.n_actuators):
264 actuator = QtWidgets.QSlider(QtCore.Qt.Horizontal, parent=self)
265 actuator.setMinimum(0)
266 actuator.setMaximum(100)
267
268 def setThisActuator(value, actuator_index=i):
269 self.setActuatorValue(actuator_index, value)
270
271 actuator.valueChanged.connect(setThisActuator)
272 self._actuators.append(actuator)
273 # We don't know the pattern currently applied to the mirror so
274 # we reset it which also updates the slider positions.
275 self.resetPattern()
276
277 self._reset_button = QtWidgets.QPushButton("Reset", parent=self)
278 self._reset_button.clicked.connect(self.resetPattern)
279
280 main_layout = QtWidgets.QVBoxLayout()
281 main_layout.addWidget(self._reset_button)
282
283 actuators_layout = QtWidgets.QFormLayout()
284 actuators_layout.setLabelAlignment(QtCore.Qt.AlignRight)
285 for i, actuator in enumerate(self._actuators, start=1):
286 actuators_layout.addRow(str(i), actuator)
287 main_layout.addLayout(actuators_layout)
288
289 self.setLayout(main_layout)
290
291 def setActuatorValue(self, actuator_index: int, value: int) -> None:
292 if not (0 < actuator_index < self._pattern.size):
293 raise ValueError(
294 "index %d is invalid because DM has %d actuators"
295 % (actuator_index, self._pattern.size)
296 )
297 self._pattern[actuator_index] = value / 100.0
298 self._device.apply_pattern(self._pattern)
299
300 def resetPattern(self) -> None:
301 """Set all actuators to their mid-point (0.5)."""
302 self._pattern.fill(0.5)
303 self._device.apply_pattern(self._pattern)
304 for i, actuator in enumerate(self._actuators):
305 actuator.blockSignals(True)
306 actuator.setSliderPosition(int(self._pattern[i] * 100))
307 actuator.blockSignals(False)
308
309
310class FilterWheelWidget(QtWidgets.QWidget):
311 """Group of toggle push buttons to change filter position.
312
313 This widget shows a table of toggle buttons with the filterwheel
314 position numbers.
315 """
316
317 def __init__(
318 self, device: microscope.abc.FilterWheel, *args, **kwargs
319 ) -> None:
320 super().__init__(*args, **kwargs)
321 self._device = device
322
323 self._button_grp = QtWidgets.QButtonGroup(self)
324 for i in range(self._device.n_positions):
325 button = QtWidgets.QPushButton(str(i + 1), parent=self)
326 button.setCheckable(True)
327 self._button_grp.addButton(button, i)
328 self._button_grp.button(self._device.position).setChecked(True)
329
330 # We use buttonClicked instead of idClicked because that
331 # requires Qt 5.15 which is too recent. Once we can use
332 # idClicked, then the slot will automatically get the wanted
333 # position.
334 self._button_grp.buttonClicked.connect(self.setFilterWheelPosition)
335
336 layout = QtWidgets.QVBoxLayout()
337 for button in self._button_grp.buttons():
338 layout.addWidget(button)
339 self.setLayout(layout)
340
341 def setFilterWheelPosition(self) -> None:
342 self._device.position = self._button_grp.checkedId()
343
344
345class LightSourceWidget(QtWidgets.QWidget):
346 def __init__(
347 self, device: microscope.abc.LightSource, *args, **kwargs
348 ) -> None:
349 super().__init__(*args, **kwargs)
350 self._device = device
351
352 self._enable_check = QtWidgets.QCheckBox("Enabled", parent=self)
353 self._enable_check.stateChanged.connect(self.updateEnableState)
354
355 self._set_power_box = QtWidgets.QDoubleSpinBox(parent=self)
356 self._set_power_box.setMinimum(0.0)
357 self._set_power_box.setMaximum(1.0)
358 self._set_power_box.setValue(self._device.power)
359 self._set_power_box.setSingleStep(0.01)
360 self._set_power_box.setAlignment(QtCore.Qt.AlignRight)
361 self._set_power_box.valueChanged.connect(
362 lambda x: setattr(self._device, "power", x)
363 )
364
365 self._current_power = QtWidgets.QLineEdit(
366 str(self._device.power), parent=self
367 )
368 self._current_power.setReadOnly(True)
369 self._current_power.setAlignment(QtCore.Qt.AlignRight)
370
371 self._get_power_timer = QtCore.QTimer(self)
372 self._get_power_timer.timeout.connect(self.updateCurrentPower)
373 self._get_power_timer.setInterval(500) # msec
374
375 self.updateEnableState()
376
377 layout = QtWidgets.QVBoxLayout()
378 layout.addWidget(self._enable_check)
379 power_layout = QtWidgets.QFormLayout()
380 power_layout.addRow("Set power", self._set_power_box)
381 power_layout.addRow("Current power", self._current_power)
382 layout.addLayout(power_layout)
383 self.setLayout(layout)
384
385 def updateEnableState(self) -> None:
386 """Update UI and light source state after enable check box"""
387 if self._enable_check.isChecked():
388 self._device.enable()
389 else:
390 self._device.disable()
391
392 device_is_enabled = self._device.get_is_enabled()
393
394 if self._enable_check.isChecked() != device_is_enabled:
395 self._enable_check.setChecked(device_is_enabled)
396 _logger.error(
397 "failed to %s light",
398 "enable" if self._enable_check.isChecked() else "disable",
399 )
400
401 self._current_power.setEnabled(device_is_enabled)
402 if device_is_enabled:
403 self._get_power_timer.start()
404 else:
405 self._get_power_timer.stop()
406 self._current_power.setText("0.0")
407
408 def updateCurrentPower(self) -> None:
409 self._current_power.setText(str(self._device.power))
410
411
412class StageWidget(QtWidgets.QWidget):
413 """Stage widget displaying each of the axis position.
414
415 This widget shows each of the axis, their limits, and a spin box
416 to change the axis position. This requires the stage to be
417 enabled since otherwise it is not able to move it or query the
418 limits.
419 """
420
421 def __init__(self, device: microscope.abc.Stage, *args, **kwargs) -> None:
422 super().__init__(*args, **kwargs)
423 self._device = device
424
425 layout = QtWidgets.QFormLayout(self)
426 for name, axis in self._device.axes.items():
427 label = "%s (%s : %s)" % (
428 name,
429 axis.limits.lower,
430 axis.limits.upper,
431 )
432
433 position_box = QtWidgets.QDoubleSpinBox(parent=self)
434 position_box.setMinimum(axis.limits.lower)
435 position_box.setMaximum(axis.limits.upper)
436 position_box.setValue(axis.position)
437 position_box.setSingleStep(1.0)
438
439 def setPositionSlot(position: float, name: str = name):
440 return self.setPosition(name, position)
441
442 position_box.valueChanged.connect(setPositionSlot)
443
444 layout.addRow(label, position_box)
445 self.setLayout(layout)
446
447 def setPosition(self, name: str, position: float) -> None:
448 self._device.axes[name].move_to(position)
449
450
451class MainWindow(QtWidgets.QMainWindow):
452 def __init__(self, widget: QtWidgets.QWidget, *args, **kwargs) -> None:
453 super().__init__(*args, **kwargs)
454
455 scroll = QtWidgets.QScrollArea()
456 scroll.setWidgetResizable(True)
457 scroll.setWidget(widget)
458
459 self.setCentralWidget(scroll)
460 for key, slot in (
461 (QtGui.QKeySequence.Quit, self.close),
462 (QtGui.QKeySequence.Close, self.close),
463 ):
464 shortcut = QtWidgets.QShortcut(key, self)
465 shortcut.activated.connect(slot)
466
467
468def _guess_device_widget(device) -> QtWidgets.QWidget:
469 if hasattr(device, "axes"):
470 return StageWidget
471 elif hasattr(device, "devices"):
472 return ControllerWidget
473 elif hasattr(device, "n_positions"):
474 return FilterWheelWidget
475 elif hasattr(device, "power"):
476 return LightSourceWidget
477 elif hasattr(device, "n_actuators"):
478 return DeformableMirrorWidget
479 elif hasattr(device, "get_sensor_shape"):
480 return CameraWidget
481 elif hasattr(device, "get_all_settings"):
482 return DeviceSettingsWidget
483 else:
484 raise TypeError("device is not a Microscope Device")
485
486
487def main(argv: typing.Sequence[str]) -> int:
488 app = QtWidgets.QApplication(argv)
489 app.setApplicationName("Microscope GUI")
490 app.setOrganizationDomain("python-microscope.org")
491
492 type_to_widget = {
493 "Camera": CameraWidget,
494 "Controller": ControllerWidget,
495 "DeformableMirror": DeformableMirrorWidget,
496 "DeviceSettings": DeviceSettingsWidget,
497 "FilterWheel": FilterWheelWidget,
498 "LightSourceWidget": LightSourceWidget,
499 "Stage": StageWidget,
500 }
501
502 parser = argparse.ArgumentParser(prog="microscope-gui")
503
504 # Although we have a function that can guess the device type from
505 # the attributes on the proxy this is still useful. For example,
506 # to display the device settings instead of the device UI. Or
507 # maybe we're dealing with a device that implements more than one
508 # interface (earlier iterations of aurox Clarity were both a
509 # camera and a filterwheel). This option provides a way to force
510 # a specific widget.
511 parser.add_argument(
512 "type",
513 action="store",
514 type=str,
515 metavar="DEVICE-TYPE",
516 choices=type_to_widget.keys(),
517 help="Type of device/widget to show",
518 )
519
520 parser.add_argument(
521 "uri",
522 action="store",
523 type=str,
524 metavar="DEVICE-URI",
525 help="URI for device",
526 )
527 args = parser.parse_args(app.arguments()[1:])
528
529 device = Pyro4.Proxy(args.uri)
530 widget_cls = type_to_widget[args.type]
531 widget = widget_cls(device)
532 window = MainWindow(widget)
533 window.show()
534
535 return app.exec_()
536
537
538def _setuptools_entry_point() -> int:
539 # The setuptools entry point must be a function, we can't simply
540 # name this module even if this module does work as a script. We
541 # also do not want to set the default of main() to sys.argv
542 # because when the documentation is generated (with Sphinx's
543 # autodoc extension), then sys.argv gets replaced with the
544 # sys.argv value at the time docs were generated (see
545 # https://stackoverflow.com/a/12087750 )
546 return main(sys.argv)
547
548
549if __name__ == "__main__":
550 sys.exit(main(sys.argv))
None fetchLoop(self)
Definition: gui.py:167
None displayData(self, numpy.ndarray data)
Definition: gui.py:236
None updateEnableState(self)
Definition: gui.py:219
None toggleDeviceWidget(self, QtWidgets.QAbstractButton button, bool checked)
Definition: gui.py:112
None setActuatorValue(self, int actuator_index, int value)
Definition: gui.py:291
None setFilterWheelPosition(self)
Definition: gui.py:341
None updateEnableState(self)
Definition: gui.py:385
None updateCurrentPower(self)
Definition: gui.py:408
None setPosition(self, str name, float position)
Definition: gui.py:447