Commit 2c64846a authored by Matias Guijarro's avatar Matias Guijarro
Browse files

Merge branch 'improve-roi-handling' into 'master'

Flint: Improve ROI display and edition

Closes #2667 and #2737

See merge request !3706
parents 700dc002 9fe3277a
Pipeline #46964 passed with stages
in 120 minutes and 8 seconds
......@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a content menu option to center profile ROIs in image/scatter
- Added curve stack as custom plot
- Added an overlay with size of Lima rect ROIs
- Added tools to duplicate/rename ROIs during ROI edition
- Better handling of timeout, and try not to have 30s
- Better handling of stucked state
- Added `restart_flint` command from `bliss.common.plot`
......@@ -31,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `--enable-watchdog` command line argument to log and kill Flint if
too much memory is used
- `scan_info["requests"]` is not anymore read (replaced by `channels`)
- Update to silx 0.15
- Update to silx 0.15.1
- Demo
- Added regulation mock to the demo session
- Scan publication
......@@ -58,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed slow rendering occurred on live curves and scatters with fast scans
- The video image is now also used for Lima EXTRERNAL_TRIGGER and EXTERNAL_GATE
- Fixed blinking of the regulation plot legend
- Fixed undisplayed ROIs during a scan. A tool is provided to display them
if not already selected.
### Removed
......
......@@ -167,7 +167,6 @@ class ScanModelReader:
self._acquisition_chain_description = scan_info.get("acquisition_chain", {})
self._device_description = scan_info.get("devices", {})
self._channel_description = scan_info.get("channels", {})
self._rois_description = scan_info.get("rois", {})
scan_info = self._scan_info
is_group = scan_info.get("is-scan-sequence", False)
......@@ -321,26 +320,32 @@ class ScanModelReader:
pass
class LimaRoiDeviceParser(DefaultDeviceParser):
def parse_channels(self, device, meta):
def parse_channels(self, device: scan_model.Device, meta: Dict):
# cache virtual roi devices
virtual_rois = {}
# FIXME: It would be good to have a real ROI concept in BLISS
# Here we iterate the set of metadata to try to find something interesting
for roi_name, roi_dict in meta.items():
if not isinstance(roi_dict, dict):
continue
if "kind" not in roi_dict:
continue
roi_device = self.create_virtual_roi(roi_name, roi_dict, device)
virtual_rois[roi_name] = roi_device
def get_virtual_roi(channel_fullname):
"""Some magic to create virtual device for each ROIs"""
"""Retrieve roi device from channel name"""
nonlocal virtual_rois
short_name = channel_fullname.rsplit(":", 1)[-1]
# FIXME: It would be good to have a real ROI concept in BLISS
if "_" in short_name:
roi_name, _ = short_name.rsplit("_", 1)
else:
roi_name = short_name
key = f"{device.master().name()}:{device.name()}:{roi_name}"
if key in virtual_rois:
return virtual_rois[key]
roi_device = self.create_virtual_roi(roi_name, key, device)
virtual_rois[key] = roi_device
return roi_device
return virtual_rois.get(roi_name, None)
channel_names = meta.get("channels", [])
for channel_fullname in channel_names:
......@@ -354,22 +359,32 @@ class ScanModelReader:
)
continue
roi_device = get_virtual_roi(channel_fullname)
self.parse_channel(channel_fullname, channel_meta, parent=roi_device)
if roi_device is not None:
parent_channel = roi_device
else:
parent_channel = device
self.parse_channel(
channel_fullname, channel_meta, parent=parent_channel
)
def create_virtual_roi(self, roi_name, key, parent):
def create_virtual_roi(self, roi_name, roi_dict, parent):
device = scan_model.Device(self.reader._scan)
device.setName(roi_name)
device.setMaster(parent)
device.setType(scan_model.DeviceType.VIRTUAL_ROI)
# Read metadata
roi_dict = self.reader._rois_description.get(key)
roi = None
if roi_dict is not None:
try:
roi = lima_roi.dict_to_roi(roi_dict)
except Exception:
_logger.warning("Error while reading roi '%s'", key, exc_info=True)
_logger.warning(
"Error while reading roi '%s' from '%s'",
roi_name,
device.fullName(),
exc_info=True,
)
metadata = scan_model.DeviceMetadata({}, roi)
device.setMetadata(metadata)
......@@ -630,6 +645,9 @@ def create_plot_model(
Use the `plots` description or infer the plots from the `acquisition_chain`.
Finally update the selection using `_display_extra`.
"""
if scan is None:
scan = create_scan_model(scan_info)
if "plots" in scan_info:
plots = read_plot_models(scan_info)
for plot in plots:
......@@ -642,12 +660,12 @@ def create_plot_model(
return True
return False
aq_plots = infer_plot_models(scan_info)
aq_plots = infer_plot_models(scan)
for plot in aq_plots:
if not contains_default_plot_kind(plots, plot):
plots.append(plot)
else:
plots = infer_plot_models(scan_info)
plots = infer_plot_models(scan)
def filter_with_scan_content(channel_names, scan):
if scan is None:
......@@ -890,7 +908,35 @@ def _infer_default_scatter_plot(scan_info: Dict) -> List[plot_model.Plot]:
return plots
def infer_plot_models(scan_info: Dict) -> List[plot_model.Plot]:
def _initialize_image_plot_from_device(device: scan_model.Device) -> plot_model.Plot:
"""Initialize ImagePlot with default information which can be used
structurally"""
plot = plot_item_model.ImagePlot()
# Reach a name which is stable between 2 scans
# FIXME: This have to be provided by the scan_info
def get_stable_name(device):
for channel in device.channels():
name = channel.name()
return name.rsplit(":", 1)[0]
return device.fullName().split(":", 1)[1]
stable_name = get_stable_name(device)
plot.setDeviceName(stable_name)
if device.type() == scan_model.DeviceType.LIMA:
for sub_device in device.devices():
if sub_device.name() in ["roi_counters", "roi_profiles"]:
for roi_device in sub_device.devices():
if roi_device.type() != scan_model.DeviceType.VIRTUAL_ROI:
continue
item = plot_item_model.RoiItem(plot)
item.setDeviceName(roi_device.fullName())
plot.addItem(item)
return plot
def infer_plot_models(scan: scan_model.Scan) -> List[plot_model.Plot]:
"""Infer description of plot models from a scan_info using
`acquisition_chain`.
......@@ -907,6 +953,7 @@ def infer_plot_models(scan_info: Dict) -> List[plot_model.Plot]:
result: List[plot_model.Plot] = []
default_plot = None
scan_info = scan.scanInfo()
acquisition_chain = scan_info.get("acquisition_chain", None)
if len(acquisition_chain.keys()) == 1:
......@@ -1043,45 +1090,23 @@ def infer_plot_models(scan_info: Dict) -> List[plot_model.Plot]:
# Image plot
for master_name in acquisition_chain.keys():
for device_id in acquisition_chain[master_name].get("devices", []):
device_info = scan_info["devices"].get(device_id, {})
device_type = device_info.get("type")
device_name = device_id.rsplit(":", 1)[-1]
plot = None
for channel_name in device_info.get("channels", []):
channel_info = scan_info["channels"].get(channel_name, {})
dim = channel_info.get("dim", 0)
if dim != 2:
continue
for device in scan.devices():
plot = None
for channel in device.channels():
if channel.type() != scan_model.ChannelType.IMAGE:
continue
if plot is None:
plot = plot_item_model.ImagePlot()
device_name = get_device_from_channel(channel_name)
plot.setDeviceName(device_name)
if default_plot is None:
default_plot = plot
if device_type == "lima":
if "rois" in scan_info:
for roi_name, _roi_dict in scan_info["rois"].items():
if not roi_name.startswith(
f"{device_name}:roi_counters:"
) and not roi_name.startswith(
f"{device_name}:roi_profiles:"
):
pass
item = plot_item_model.RoiItem(plot)
item.setDeviceName(f"{master_name}:{roi_name}")
plot.addItem(item)
image_channel = plot_model.ChannelRef(plot, channel_name)
item = plot_item_model.ImageItem(plot)
item.setImageChannel(image_channel)
plot.addItem(item)
if plot is None:
plot = _initialize_image_plot_from_device(device)
if default_plot is None:
default_plot = plot
if plot is not None:
result.append(plot)
image_channel = plot_model.ChannelRef(plot, channel.name())
item = plot_item_model.ImageItem(plot)
item.setImageChannel(image_channel)
plot.addItem(item)
if plot is not None:
result.append(plot)
# Move the default plot on top
if default_plot is not None:
......
......@@ -475,6 +475,12 @@ class Device(qt.QObject, _Sealable):
def scan(self) -> Scan:
return self.parent()
def devices(self) -> List[Device]:
"""List sub devices from this device"""
for d in self.scan().devices():
if d.isChildOf(self):
yield d
def seal(self):
for channel in self.__channels:
channel.seal()
......
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg39" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata43"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><path id="rect821-6" d="m9.3929 22.623h-6.7611v-19.375h16.37v1.9586" fill="none" stroke="#00a14b" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"/><rect id="rect821" x="12.323" y="8.1873" width="16.795" height="20.315" fill="none" stroke="#00a14b" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.5"/></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg39" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata43"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><g id="g930" transform="rotate(-45 32.518 54.963)"><rect id="rect862" x="44.639" y="10.919" width="18.389" height="10.566" fill="none" stroke="#00a14b" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.6"/><rect id="rect882" x="61.554" y="10.919" width="1.4744" height="10.435" fill="#00a14b"/><path id="path895" d="m44.639 10.919-5.4729 5.283 5.4729 5.283z" fill="none" stroke="#00a14b" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.6"/><path id="path895-5" d="m41.608 14.582-1.6783 1.62 1.6783 1.62z" fill="#00a14b"/></g><path id="rect932" d="m28.416 16.388v10.542h-24.833v-16.875h6.9444" fill="none" stroke="#00a14b" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.5"/></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg10" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata2"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><g id="g8" transform="matrix(12.389 0 0 12.389 -774.02 -321.87)"><path id="path4" d="m64.059 26.477h0.52917v1.5875h-0.52917" fill="none" stroke="#00a14b" stroke-linecap="square" stroke-width=".20179"/><path id="path6" d="m63.397 26.422v0.45117h-0.5293v0.79492h0.5293v0.45117l0.84961-0.84766z" color="#000000" color-rendering="auto" dominant-baseline="auto" enable-background="accumulate" fill="#00a14b" image-rendering="auto" shape-rendering="auto" solid-color="#000000" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;isolation:auto;mix-blend-mode:normal;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/></g></svg>
......@@ -32,14 +32,7 @@ class QTreeView(qt.QTreeView):
self.closePersistentEditor = self._closePersistentEditor
def _uniqueId(self, index):
if not index.isValid():
return None
path = []
while index.isValid():
path.append(index.row())
path.append(index.column())
index = index.parent()
return tuple(path)
return qt.QPersistentModelIndex(index)
def _isPersistentEditorOpen(self, index):
unique = self._uniqueId(index)
......
......@@ -581,21 +581,18 @@ class ImagePlotWidget(plot_helper.PlotWidget):
if self.__scan is None:
return
master = None
limaDevice = None
for device in self.__scan.devices():
if device.name() != self.deviceName():
if device.type() != scan_model.DeviceType.LIMA:
continue
if device.master() and device.master().isMaster():
master = device
if device.name() == self.deviceName():
limaDevice = device
break
if master is None:
if limaDevice is None:
return
for device in self.__scan.devices():
if not device.isChildOf(master):
continue
for device in limaDevice.devices():
roi = device.metadata().roi
if roi is None:
continue
......
......@@ -90,6 +90,17 @@ class _DataItem(_property_tree_helper.ScanRowItem):
self.setDeviceLookAndFeel(device)
self.__used.setCheckable(False)
if device.type() == scan_model.DeviceType.VIRTUAL_ROI:
state = qt.Qt.Unchecked
self.__displayed.modelUpdated = None
self.__displayed.setData(state, role=delegates.VisibilityRole)
self.__displayed.modelUpdated = weakref.WeakMethod(
self.__visibilityViewChanged
)
if self.__treeView.isPersistentEditorOpen(self.__displayed.index()):
self.__treeView.closePersistentEditor(self.__displayed.index())
self.__treeView.openPersistentEditor(self.__displayed.index())
def device(self):
return self.__device
......@@ -118,17 +129,21 @@ class _DataItem(_property_tree_helper.ScanRowItem):
if plotItem is not None:
isVisible = plotItem.isVisible()
state = qt.Qt.Checked if isVisible else qt.Qt.Unchecked
self.__displayed.modelUpdated = None
self.__displayed.setData(state, role=delegates.VisibilityRole)
self.__displayed.modelUpdated = weakref.WeakMethod(
self.__visibilityViewChanged
)
else:
self.__displayed.setData(None, role=delegates.VisibilityRole)
self.__displayed.modelUpdated = None
self.__displayed.setData(None, role=delegates.VisibilityRole)
if self.__channel is None:
self.setPlotItemLookAndFeel(plotItem)
if self.__treeView.isPersistentEditorOpen(self.__displayed.index()):
_logger.error("close")
self.__treeView.closePersistentEditor(self.__displayed.index())
self.__treeView.openPersistentEditor(self.__displayed.index())
if not isRoiItem:
self.__treeView.openPersistentEditor(self.__remove.index())
......
......@@ -10,8 +10,12 @@ Provide a RoiSelectionWidget
"""
import typing
import logging
import functools
import re
from silx.gui import qt
from silx.gui import icons
from silx.gui.plot.tools.roi import RegionOfInterestManager
from silx.gui.plot.tools.roi import RegionOfInterestTableWidget
from silx.gui.plot.items.roi import RectangleROI
......@@ -19,6 +23,9 @@ from silx.gui.plot.items.roi import RegionOfInterest
from silx.gui.plot.tools.roi import RoiModeSelectorAction
_logger = logging.getLogger(__name__)
class _AutoHideToolBar(qt.QToolBar):
"""A toolbar which hide itself if no actions are visible"""
......@@ -36,6 +43,24 @@ class _AutoHideToolBar(qt.QToolBar):
self.setVisible(visible)
class _RegionOfInterestManagerWithContextMenu(RegionOfInterestManager):
sigRoiContextMenuRequested = qt.Signal(object, qt.QMenu)
def _feedContextMenu(self, menu):
RegionOfInterestManager._feedContextMenu(self, menu)
roi = self.getCurrentRoi()
if roi is not None:
if roi.isEditable():
self.sigRoiContextMenuRequested.emit(roi, menu)
def getRoiByName(self, name):
for r in self.getRois():
if r.getName() == name:
return r
return None
class RoiSelectionWidget(qt.QWidget):
selectionFinished = qt.Signal(object)
......@@ -48,21 +73,52 @@ class RoiSelectionWidget(qt.QWidget):
mode = plot.getInteractiveMode()["mode"]
self.__previousMode = mode
self.roiManager = RegionOfInterestManager(plot)
self.roiManager = _RegionOfInterestManagerWithContextMenu(plot)
self.roiManager.setColor("pink")
self.roiManager.sigRoiAdded.connect(self.__roiAdded)
self.roiManager.sigRoiContextMenuRequested.connect(self.roiContextMenuRequested)
self.roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged)
self.table = RegionOfInterestTableWidget()
self.table.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
self.table.setSelectionMode(qt.QAbstractItemView.SingleSelection)
selectionModel = self.table.selectionModel()
selectionModel.currentRowChanged.connect(self.__currentRowChanged)
# Hide coords
horizontalHeader = self.table.horizontalHeader()
horizontalHeader.setSectionResizeMode(0, qt.QHeaderView.Stretch)
horizontalHeader.hideSection(3)
horizontalHeader.hideSection(1) # is editable
horizontalHeader.hideSection(3) # coords
self.table.setRegionOfInterestManager(self.roiManager)
if kinds is None:
kinds = [RectangleROI]
self.roiToolbar = qt.QToolBar(self)
cloneAction = qt.QAction(self.roiManager)
cloneAction.setText("Duplicate")
cloneAction.setToolTip("Duplicate selected ROI")
icon = icons.getQIcon("flint:icons/roi-duplicate")
cloneAction.setIcon(icon)
cloneAction.setEnabled(False)
cloneAction.triggered.connect(self.cloneCurrentRoiRequested)
self.__cloneAction = cloneAction
renameAction = qt.QAction(self.roiManager)
renameAction.setText("Rename")
renameAction.setToolTip("Rename selected ROI")
icon = icons.getQIcon("flint:icons/roi-rename")
renameAction.setIcon(icon)
renameAction.setEnabled(False)
renameAction.triggered.connect(self.renameCurrentRoiRequested)
self.__renameAction = renameAction
self.roiToolbar.addAction(cloneAction)
self.roiToolbar.addAction(renameAction)
self.roiToolbar.addSeparator()
firstAction = None
for roiKind in kinds:
action = self.roiManager.getInteractionModeAction(roiKind)
......@@ -75,8 +131,15 @@ class RoiSelectionWidget(qt.QWidget):
applyAction.setText("Apply")
applyAction.triggered.connect(self.on_apply)
applyAction.setObjectName("roi-apply-selection")
self.roiToolbar.addSeparator()
self.roiToolbar.addAction(applyAction)
self.addAction(applyAction)
self.applyButton = qt.QPushButton(self)
self.applyButton.setFixedHeight(40)
self.applyButton.setText("Apply this ROIs")
icon = icons.getQIcon("flint:icons/roi-save")
self.applyButton.setIcon(icon)
self.applyButton.clicked.connect(self.on_apply)
self.applyButton.setIconSize(qt.QSize(24, 24))
roiEditToolbar = _AutoHideToolBar(self)
modeSelectorAction = RoiModeSelectorAction(self)
......@@ -90,14 +153,135 @@ class RoiSelectionWidget(qt.QWidget):
layout.addWidget(self.roiToolbar)
layout.addWidget(self.roiEditToolbar)
layout.addWidget(self.table)
layout.addWidget(self.applyButton)
if firstAction is not None:
firstAction.trigger()
def __currentRowChanged(self, current, previous):
model = self.table.model()
index = model.index(current.row(), 0)
name = model.data(index)
roi = self.roiManager.getRoiByName(name)
self.roiManager.setCurrentRoi(roi)
def __currentRoiChanged(self, roi):
selectionModel = self.table.selectionModel()
if roi is None:
selectionModel.clear()
enabled = False
else:
name = roi.getName()
model = self.table.model()
for row in range(model.rowCount()):
index = model.index(row, 0)
if model.data(index) == name:
selectionModel.reset()
mode = (
qt.QItemSelectionModel.Clear
| qt.QItemSelectionModel.Rows
| qt.QItemSelectionModel.Current
| qt.QItemSelectionModel.Select
)
selectionModel.select(index, mode)
enabled = True
break
else:
selectionModel.clear()
enabled = False
self.__cloneAction.setEnabled(enabled)
self.__renameAction.setEnabled(enabled)
def on_apply(self):
self.selectionFinished.emit(self.roiManager.getRois())
self.clear()
def roiContextMenuRequested(self, roi, menu: qt.QMenu):
menu.addSeparator()
cloneAction = qt.QAction(menu)
cloneAction.setText("Duplicate %s" % roi.getName())
callback = functools.partial(self.cloneRoiRequested, roi)
cloneAction.triggered.connect(callback)
menu.addAction(cloneAction)
renameAction = qt.QAction(menu)
renameAction.setText("Rename %s" % roi.getName())
callback = functools.partial(self.renameRoiRequested, roi)
renameAction.triggered.connect(callback)
menu.addAction(renameAction)
def renameRoiRequested(self, roi):
name = roi.getName()
result = qt.QInputDialog.getText(
self, "Rename ROI name", "ROI name", qt.QLineEdit.Normal, name
)
if result[1]:
newName = result[0]
if newName == name:
return
if self.isAlreadyUsed(newName):
qt.QMessageBox.warning(
self, "Action cancelled", f"ROI name '{newName}' already used."
)
return
roi.setName(newName)
def __splitTrailingNumber(self, name):
m = re.search(r"^(.*?)(\d+)$", name)
if m is None:
return name, 1
groups = m.groups()
return groups[0], int(groups[1])
def cloneRoiRequested(self, roi):
name = roi.getName()
basename, number = self.__splitTrailingNumber(name)
for _ in range(50):
number = number + 1
name = f"{basename}{number}"
if not self.isAlreadyUsed(name):
break
result = qt.QInputDialog.getText(
self, "Clone ROI", "ROI name", qt.QLineEdit.Normal, name
)
if result[1]:
if self.isAlreadyUsed(name):
qt.QMessageBox.warning(
self, "Action cancelled", f"ROI name '{name}' already used."
)
return
try:
newRoi = roi.clone()
except Exception: