Source code for QVideo.lib.QCameraTree
'''Auto-building pyqtgraph parameter tree for QCamera property inspection.'''
from qtpy import QtCore, QtGui
from pyqtgraph.parametertree import Parameter, ParameterTree
from QVideo.lib import QCamera, QVideoSource
import math
import logging
__all__ = ['QCameraTree']
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
Source = QCamera | QVideoSource
Description = list[dict[str, object]]
Change = tuple[Parameter, str, QCamera.PropertyValue]
Changes = list[Change]
[docs]
class QCameraTree(ParameterTree):
'''A parameter tree widget for controlling
:class:`~QVideo.lib.QCamera.QCamera` properties.
Wraps a :class:`~QVideo.lib.QVideoSource.QVideoSource` (or a bare
:class:`~QVideo.lib.QCamera.QCamera`) and presents its settings as
an editable :class:`~pyqtgraph.parametertree.ParameterTree`.
Changes made in the tree are pushed to the camera; camera-side
changes are reflected back into the tree.
Parameters
----------
source : QCamera or QVideoSource
The video source to control. Must already be open.
description : list[dict] or None
Parameter-tree description of the camera properties to expose.
If ``None`` a default description is generated from
:attr:`~QVideo.lib.QCamera.QCamera.settings`.
*args :
Additional positional arguments forwarded to
:class:`~pyqtgraph.parametertree.ParameterTree`.
**kwargs :
Additional keyword arguments forwarded to
:class:`~pyqtgraph.parametertree.ParameterTree`.
Raises
------
RuntimeError
If *source* is not open when the tree is created.
'''
@classmethod
def _getParameters(cls, parameter: Parameter) -> dict[str, Parameter]:
'''Recursively collect leaf
:class:`~pyqtgraph.parametertree.Parameter` nodes.'''
parameters = dict()
for child in parameter.children():
if child.hasChildren():
parameters.update(cls._getParameters(child))
else:
parameters.update({child.name(): child})
return parameters
@staticmethod
def _defaultDescription(camera: QCamera) -> Description:
entries = []
for name, spec in camera._properties.items():
value = spec['getter']()
if value is None:
continue
entry = {'name': name,
'type': type(value).__name__,
'value': value,
'default': value}
if spec['setter'] is None:
entry['enabled'] = False
entries.append(entry)
return entries
def __init__(self,
source: Source,
description: Description | None = None,
*args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if not source.isOpen():
raise RuntimeError('Video source is not open')
if isinstance(source, QCamera):
self._source = QVideoSource(source)
else:
self._source = source
self._ignoreSync = False
self._createTree(description)
self._connectSignals()
self._setupUi()
[docs]
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
'''Stop the video source when the widget is closed.'''
self.stop()
super().closeEvent(event)
def _createTree(self, description: Description | None) -> None:
if description is None:
description = self._defaultDescription(self.camera)
logger.debug(description)
self._tree = Parameter.create(
name=self.camera.model_name or self.camera.name,
type='group',
children=description)
self.setParameters(self._tree)
self._parameters = self._getParameters(self._tree)
for param in self._parameters.values():
self._updateStep(param)
def _connectSignals(self) -> None:
self._tree.sigTreeStateChanged.connect(self._sync)
QtCore.QCoreApplication.instance().aboutToQuit.connect(self.stop)
def _setupUi(self) -> None:
header = self.header()
self.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight)
self.setIndentation(10)
count = self.columnCount()
for n in range(count - 1):
header.setSectionResizeMode(n, header.ResizeMode.Interactive)
self.resizeColumnToContents(n)
header.setSectionResizeMode(count - 1, header.ResizeMode.Stretch)
self.adjustSize()
self.setMinimumWidth(self.width())
@staticmethod
def _updateStep(param: Parameter) -> None:
'''Scale a float SpinBox's step to ~10% of its current magnitude.
pyqtgraph's ``dec=True`` mode jumps by a whole power of ten per
scroll click, which is too coarse for camera properties (e.g.
exposure time) that can span several orders of magnitude.
Recomputing the step from the live value on every change keeps
scrolling proportional at every magnitude instead.
'''
if param.opts.get('type') != 'float':
return
try:
value = float(param.value())
except (TypeError, ValueError):
return
if value == 0:
return
step = 10 ** (math.floor(math.log10(abs(value))) - 1)
param.setOpts(step=step)
@QtCore.Slot(object, object)
def _sync(self, root: Parameter, changes: Changes) -> None:
if self._ignoreSync:
return
for param, change, value in changes:
if (change == 'value'):
key = param.name()
logger.debug(f'Syncing {key}: {change}: {value}')
self.camera.set(key, value)
self._updateStep(param)
self._ignoreSync = True
for key, value in self.camera.settings.items():
self.set(key, value)
self._ignoreSync = False
[docs]
@QtCore.Slot(str, object)
def set(self, key: str, value: QCamera.PropertyValue) -> None:
'''Set a camera property and update the tree.
Parameters
----------
key : str
Property name.
value : QCamera.PropertyValue
New value.
'''
if key in self._parameters:
logger.debug(f'set {key}: {value}')
param = self._parameters[key]
param.setValue(value)
self._updateStep(param)
else:
logger.warning(f'Unsupported property: {key}')
[docs]
def get(self, key: str) -> QCamera.PropertyValue | None:
'''Get a camera property value from the tree.
Parameters
----------
key : str
Property name.
Returns
-------
QCamera.PropertyValue or None
Current value, or ``None`` if *key* is not in the tree.
'''
if key in self._parameters:
return self._parameters[key].value()
logger.warning(f'Unsupported property: {key}')
return None
@property
def source(self) -> QVideoSource:
'''The underlying :class:`~QVideo.lib.QVideoSource.QVideoSource`.'''
return self._source
@property
def camera(self) -> QCamera:
'''The :class:`~QVideo.lib.QCamera.QCamera` driven by this tree.'''
return self.source.source
[docs]
@QtCore.Slot()
def start(self) -> 'QCameraTree':
'''Start the video source.
Returns
-------
QCameraTree
``self``, to allow chaining
(e.g. ``tree = QCameraTree(...).start()``).
'''
self.source.start()
return self
[docs]
@QtCore.Slot()
def stop(self) -> None:
'''Stop and join the video source thread.'''
if self.source.isRunning():
self.source.stop()
self.source.quit()
self.source.wait()
[docs]
@classmethod
def example(cls: type['QCameraTree']) -> None: # pragma: no cover
'''Demonstrate the widget with a default camera source.'''
import pyqtgraph as pg
app = pg.mkQApp(f'{cls.__name__} Example')
tree = cls().start()
tree.show()
app.exec()