diff --git a/laserstudio/instruments/camera_usb.py b/laserstudio/instruments/camera_usb.py index 86c0b29..f5408ed 100644 --- a/laserstudio/instruments/camera_usb.py +++ b/laserstudio/instruments/camera_usb.py @@ -31,7 +31,7 @@ def __init__(self, config: dict): f"Camera's resolution {self.width}px; {self.height}px" ) logging.getLogger("laserstudio").info( - f"Image's dimension {self.width_um}µm; {self.height_um}µm (without considering any magnifier)" + f"Image's dimension {self.width_um}\xa0µm; {self.height_um}\xa0µm (without considering any magnifier)" ) def __del__(self): diff --git a/laserstudio/instruments/stage.py b/laserstudio/instruments/stage.py index 1a00801..91d9a63 100644 --- a/laserstudio/instruments/stage.py +++ b/laserstudio/instruments/stage.py @@ -170,7 +170,7 @@ def move_to(self, position: Vector, wait: bool): for i, displacement in enumerate(displacement.data): if abs(displacement) > self.guardrail: logging.getLogger("laserstudio").error( - f"Do not move!! One axis ({i}) moves further than {self.guardrail}µm: {displacement}µm" + f"Do not move!! One axis ({i}) moves further than {self.guardrail}\xa0µm: {displacement}\xa0µm" ) return # Move to actual destination diff --git a/laserstudio/laserstudio.py b/laserstudio/laserstudio.py index a29008b..8398d3c 100644 --- a/laserstudio/laserstudio.py +++ b/laserstudio/laserstudio.py @@ -7,7 +7,7 @@ ) from typing import Optional, Any, Union -from .widgets.viewer import Viewer +from .widgets.viewer import Viewer, IdMarker from .instruments.instruments import ( Instruments, PDMInstrument, @@ -83,6 +83,7 @@ def __init__(self, config: Optional[dict]): # Toolbar: Markers toolbar = MarkersToolbar(self.viewer) self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) + self.addToolBar(Qt.ToolBarArea.RightToolBarArea, toolbar.markers_list_toolbar) # Toolbar: Stage positioning if self.instruments.stage is not None: @@ -175,11 +176,11 @@ def __init__(self, config: Optional[dict]): if window_state is not None: self.restoreState(window_state) - def closeEvent(self, event): + def closeEvent(self, a0): """Saves user settings before closing the application.""" self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("window-state", self.saveState()) - super().closeEvent(event) + super().closeEvent(a0) def handle_go_next(self) -> dict: """Go Next operation. @@ -236,6 +237,23 @@ def handle_position(self, pos: Optional[list[float]]) -> dict: self.instruments.stage.move_to(Vector(*pos), wait=True) return {"pos": self.instruments.stage.position.data} + def handle_markers(self) -> list[dict]: + """Handle a Markers API request to get the list of markers.""" + + return [ + { + "id": marker.id if isinstance(marker, IdMarker) else -1, + "pos": [marker.pos().x(), marker.pos().y()], + "color": [ + marker.qfillcolor.redF(), + marker.qfillcolor.greenF(), + marker.qfillcolor.blueF(), + marker.qfillcolor.alphaF(), + ], + } + for marker in self.viewer.markers + ] + def handle_add_markers( self, positions: Optional[list[list[float]]], color: Optional[list[float]] ) -> dict: diff --git a/laserstudio/lsapi/lsapi.py b/laserstudio/lsapi/lsapi.py index c247366..682e2f8 100644 --- a/laserstudio/lsapi/lsapi.py +++ b/laserstudio/lsapi/lsapi.py @@ -69,6 +69,14 @@ def autofocus(self) -> List[float]: """ return self.send("motion/autofocus").json() + def markers(self) -> List[Dict[str, Union[int, Tuple[float, float]]]]: + """ + Get the list of markers in the scene. + + :return: A list of dictionaries, each containing the marker's id, position and RGBA color. + """ + return self.send("annotation/markers").json() + def marker( self, color: Union[Tuple[float, float, float], Tuple[float, float, float, float]] = ( diff --git a/laserstudio/lsapi/setup.py b/laserstudio/lsapi/setup.py index 0fbbae4..c8631a2 100644 --- a/laserstudio/lsapi/setup.py +++ b/laserstudio/lsapi/setup.py @@ -4,7 +4,7 @@ setup( name="lsapi", - version="1.1", + version="1.2", install_requires=["requests", "Pillow"], python_requires=">=3.7", ) diff --git a/laserstudio/restserver/server.py b/laserstudio/restserver/server.py index f7a3016..4773301 100644 --- a/laserstudio/restserver/server.py +++ b/laserstudio/restserver/server.py @@ -57,6 +57,10 @@ def handle_add_markers( ): return QVariant(self.laser_studio.handle_add_markers(pos, color)) + @pyqtSlot(result="QVariant") + def handle_markers(self): + return QVariant(self.laser_studio.handle_markers()) + @pyqtSlot(QVariant, result="QVariant") def handle_position(self, pos: Optional[List[float]]): return QVariant(self.laser_studio.handle_position(pos)) @@ -311,6 +315,11 @@ def post(self): qvar = RestServer.invoke("handle_add_markers", QVariant(pos), QVariant(color)) return cast(dict, qvar) +@annotations.route("/markers") +class Markers(Resource): + def get(self): + qvar = RestServer.invoke("handle_markers") + return cast(List[dict], qvar) # instruments = flask_api.namespace("instruments", description="Control instruments") diff --git a/laserstudio/widgets/keyboardbox.py b/laserstudio/widgets/keyboardbox.py index 2322d59..fd76040 100644 --- a/laserstudio/widgets/keyboardbox.py +++ b/laserstudio/widgets/keyboardbox.py @@ -64,7 +64,7 @@ def __init__(self, stage: StageInstrument, *__args): w.setDecimals(1) w.setValue(self.displacement_z) w.valueChanged.connect(lambda v: self.__setattr__("displacement_xy", v)) - w.setSuffix(" µm") + w.setSuffix("\xa0µm") w.setSingleStep(5) grid.addWidget(w, 3, 1, 1, 3) @@ -94,7 +94,7 @@ def __init__(self, stage: StageInstrument, *__args): w.setDecimals(1) w.setValue(self.displacement_z) w.valueChanged.connect(lambda v: self.__setattr__("displacement_z", v)) - w.setSuffix(" µm") + w.setSuffix("\xa0µm") w.setSingleStep(10) grid.addWidget(w, 3, 4) diff --git a/laserstudio/widgets/marker.py b/laserstudio/widgets/marker.py index 38e1013..fa6a1da 100644 --- a/laserstudio/widgets/marker.py +++ b/laserstudio/widgets/marker.py @@ -68,8 +68,13 @@ def size(self, value): self.__update_size() @property - def color(self): + def qcolor(self) -> QColor: """:return: Current color, as QColor.""" + return QColor(self.__color) + + @property + def color(self) -> Union[QColor, Qt.GlobalColor, int]: + """:return: Current color, as QColor, Qt.GlobalColor or int.""" return self.__color @color.setter @@ -86,6 +91,11 @@ def color(self, value: Union[QColor, Qt.GlobalColor, int]): self.__line2.setPen(self.__pen) self.update() + @property + def qfillcolor(self) -> QColor: + """:return: Current fill color, as QColor.""" + return QColor(self.__fillcolor) + @property def fillcolor(self): """:return: Current fill color, as QColor.""" @@ -128,8 +138,8 @@ def update_pos(self): else: self.setVisible(False) - def setToolTip(self, value: str): - self.__ellipse.setToolTip(value) + def setToolTip(self, toolTip: Optional[str]): + self.__ellipse.setToolTip(toolTip) class IdMarker(Marker): @@ -139,7 +149,7 @@ class IdMarker(Marker): ID = 1 def __init__(self, parent=None, color=QColorConstants.Red) -> None: - super().__init__(parent, color=QColorConstants.Transparent, fillcolor=color) + super().__init__(parent, color=color, fillcolor=color) self._id = IdMarker.ID IdMarker.ID += 1 diff --git a/laserstudio/widgets/scangeometry.py b/laserstudio/widgets/scangeometry.py index 55a6094..b290343 100644 --- a/laserstudio/widgets/scangeometry.py +++ b/laserstudio/widgets/scangeometry.py @@ -74,9 +74,14 @@ def __update(self): ) else: for poly in self.__scan_geometry.geoms: - self.__scan_zones_group.addToGroup( - ScanGeometry.__poly_to_path_item(poly) - ) + if isinstance(poly, Polygon): + self.__scan_zones_group.addToGroup( + ScanGeometry.__poly_to_path_item(poly) + ) + else: + logging.getLogger("laserstudio").error( + "Unsupported geometry type in scan geometry." + ) self.addToGroup(self.__scan_zones_group) # Also, update the scan path with the new geometry @@ -146,7 +151,7 @@ def density(self, value: int): @staticmethod def shapely_to_yaml( - geometry: Union[Polygon, MultiPolygon, GeometryCollection] + geometry: Union[Polygon, MultiPolygon, GeometryCollection], ) -> dict: """ :return: A dict for YAML serialization. diff --git a/laserstudio/widgets/toolbars/cameratoolbar.py b/laserstudio/widgets/toolbars/cameratoolbar.py index 071c3ff..003b68d 100644 --- a/laserstudio/widgets/toolbars/cameratoolbar.py +++ b/laserstudio/widgets/toolbars/cameratoolbar.py @@ -90,7 +90,7 @@ def __init__(self, laser_studio: "LaserStudio"): w = QWidget() grid.addWidget(QLabel("Refresh interval:"), 3, 1) self.refresh_interval = w = ReturnSpinBox() - w.setSuffix("ms") + w.setSuffix("\xa0ms") w.setMinimum(20) w.setMaximum(10000) w.setSingleStep(10) diff --git a/laserstudio/widgets/toolbars/markerslisttoolbar.py b/laserstudio/widgets/toolbars/markerslisttoolbar.py new file mode 100644 index 0000000..0ea827c --- /dev/null +++ b/laserstudio/widgets/toolbars/markerslisttoolbar.py @@ -0,0 +1,146 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import ( + QPushButton, + QToolBar, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QHBoxLayout, + QWidget, +) +from ..viewer import Viewer +from ..marker import Marker + + +class MarkersGroupListItem(QTreeWidgetItem): + def __init__(self, parent: QTreeWidget): + super().__init__(parent) + self.number_of_checked = 0 + + def update_checked_state(self): + # To prevent the itemChanged signal from being emitted + tw = self.treeWidget() + assert tw is not None + tw.blockSignals(True) + self.setToolTip(0, f"{self.number_of_checked} shown over {self.childCount()}") + tw.blockSignals(False) + if self.number_of_checked == 0: + self.setCheckState(0, Qt.CheckState.Unchecked) + elif self.number_of_checked == self.childCount(): + self.setCheckState(0, Qt.CheckState.Checked) + else: + self.setCheckState(0, Qt.CheckState.PartiallyChecked) + + +class MarkersListItem(QTreeWidgetItem): + def __init__(self, group: MarkersGroupListItem, marker: Marker): + super().__init__(group) + self.group = group + x, y = marker.pos().x(), marker.pos().y() + self.marker = marker + visible = marker.isVisible() + self.setCheckState( + 0, Qt.CheckState.Checked if visible else Qt.CheckState.Unchecked + ) + self.setText(0, f"{x:.02f}\xa0µm, {y:.02f}\xa0µm") + self.setForeground(0, marker.fillcolor) + if visible: + group.number_of_checked += 1 + + +class MarkersListToolbar(QToolBar): + def show_selected(self): + for item in self.list.selectedItems(): + item.setCheckState(0, Qt.CheckState.Checked) + + def hide_selected(self): + for item in self.list.selectedItems(): + item.setCheckState(0, Qt.CheckState.Unchecked) + + def refresh_list(self): + self.list.clear() + markers_by_colors: dict[str, list[Marker]] = {} + for marker in self.viewer.markers: + if type(marker.fillcolor) is QColor: + name: str = f"{marker.fillcolor.hue():02x}{marker.fillcolor.saturation():02x}{marker.fillcolor.lightness():02x}{marker.fillcolor.alpha():02x}" + else: + name = str(marker.color) + if name not in markers_by_colors: + markers_by_colors[name] = [marker] + else: + markers_by_colors[name].append(marker) + for color in sorted(markers_by_colors.keys()): + markers: list[Marker] = markers_by_colors[color] + group = MarkersGroupListItem(self.list) + group.setForeground(0, markers[0].fillcolor) + group.setText( + 0, f"{len(markers)} marker" + ("" if len(markers) == 1 else "s") + ) + + self.list.itemChanged.disconnect(self.item_changed) + for marker in markers: + MarkersListItem(group, marker) + group.update_checked_state() + self.list.itemChanged.connect(self.item_changed) + + def __init__(self, viewer: Viewer): + super().__init__("Markers List") + self.setObjectName("toolbar-markers-list") # For settings save and restore + self.setAllowedAreas( + Qt.ToolBarArea.LeftToolBarArea | Qt.ToolBarArea.RightToolBarArea + ) + self.setFloatable(True) + + self.viewer = viewer + self.list = QTreeWidget() + self.list.setHeaderHidden(True) + self.list.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) + self.list.itemChanged.connect(self.item_changed) + + w = QWidget() + self.addWidget(w) + vbox = QVBoxLayout() + w.setLayout(vbox) + + w = QPushButton("Refresh") + w.clicked.connect(self.refresh_list) + vbox.addWidget(w) + + hbox = QHBoxLayout() + w = QPushButton("Show") + w.clicked.connect(self.show_selected) + hbox.addWidget(w) + w = QPushButton("Hide") + w.clicked.connect(self.hide_selected) + hbox.addWidget(w) + + vbox.addLayout(hbox) + vbox.addWidget(self.list) + + self.list.itemDoubleClicked.connect(self.show_marker) + + def show_marker(self, item: QTreeWidgetItem): + if isinstance(item, MarkersListItem): + self.viewer.follow_stage_sight = False + self.viewer.cam_pos_zoom = item.marker.pos(), self.viewer.cam_pos_zoom[1] + + def item_changed(self, item: QTreeWidgetItem): + if isinstance(item, MarkersListItem): + visible = item.checkState(0) == Qt.CheckState.Checked + was_visible = item.marker.isVisible() + if not was_visible and visible: + item.group.number_of_checked += 1 + elif was_visible and not visible: + item.group.number_of_checked -= 1 + item.marker.setVisible(visible) + item.group.update_checked_state() + if isinstance(item, MarkersGroupListItem): + new_state = item.checkState(0) + if new_state == Qt.CheckState.PartiallyChecked: + return + for i in range(item.childCount()): + child = item.child(i) + if child is None: + continue + child.setCheckState(0, new_state) diff --git a/laserstudio/widgets/toolbars/markerstoolbar.py b/laserstudio/widgets/toolbars/markerstoolbar.py index fb3fc36..e95fe7f 100644 --- a/laserstudio/widgets/toolbars/markerstoolbar.py +++ b/laserstudio/widgets/toolbars/markerstoolbar.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING from PyQt6.QtCore import Qt, QSize from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QToolBar, QPushButton from ..return_line_edit import ReturnSpinBox from ...utils.util import colored_image from ..viewer import Viewer +from .markerslisttoolbar import MarkersListToolbar class MarkersToolbar(QToolBar): @@ -18,7 +18,7 @@ def __init__(self, viewer: Viewer): w = QPushButton(self) w.setIcon(QIcon(colored_image(":/icons/location-pin-plus.svg"))) w.setIconSize(QSize(24, 24)) - w.setToolTip("Add markers") + w.setToolTip("Add marker") w.clicked.connect(lambda: viewer.add_marker()) self.addWidget(w) @@ -30,9 +30,17 @@ def __init__(self, viewer: Viewer): w.clicked.connect(viewer.clear_markers) self.addWidget(w) + # Show list of all markers + w = QPushButton(parent=self) + w.setText("Show list") + w.setToolTip("Show a list of all markers") + w.setCheckable(True) + w.clicked.connect(self.show_markers_list) + self.addWidget(w) + # Markers' size self.marker_size_sp = w = ReturnSpinBox() - self.marker_size_sp.setSuffix(" µm") + self.marker_size_sp.setSuffix("\xa0µm") self.marker_size_sp.setToolTip("Markers' size") self.marker_size_sp.setMinimum(1) self.marker_size_sp.setSingleStep(10) @@ -43,3 +51,13 @@ def __init__(self, viewer: Viewer): lambda: viewer.marker_size(float(self.marker_size_sp.value())) ) self.addWidget(self.marker_size_sp) + + # Toolbar: Markers' List + self.markers_list_toolbar = MarkersListToolbar(viewer) + + def show_markers_list(self, state: bool): + if state: + self.markers_list_toolbar.refresh_list() + self.markers_list_toolbar.show() + else: + self.markers_list_toolbar.hide() diff --git a/laserstudio/widgets/toolbars/pdmtoolbar.py b/laserstudio/widgets/toolbars/pdmtoolbar.py index 17859cf..b8f3e38 100644 --- a/laserstudio/widgets/toolbars/pdmtoolbar.py +++ b/laserstudio/widgets/toolbars/pdmtoolbar.py @@ -117,7 +117,7 @@ def __init__(self, laser: PDMInstrument, laser_num: int): w.setMinimum(0.0) w.setMaximum(150.0) # TODO read limit from pypdm if possible w.setDecimals(3) - w.setSuffix("mA") + w.setSuffix("\xa0mA") w.setValue(0) w.returnPressed.connect( lambda: self.laser.__setattr__( diff --git a/laserstudio/widgets/viewer.py b/laserstudio/widgets/viewer.py index a68fcab..bee0dfe 100644 --- a/laserstudio/widgets/viewer.py +++ b/laserstudio/widgets/viewer.py @@ -123,6 +123,10 @@ def __init__(self, parent=None): self.setMouseTracking(True) + @property + def markers(self) -> list[Marker]: + return self.__markers + def marker_size(self, value: float): self.default_marker_size = value for m in self.__markers: diff --git a/tests/test_lsapi.py b/tests/test_lsapi.py index 3998992..f0386b7 100644 --- a/tests/test_lsapi.py +++ b/tests/test_lsapi.py @@ -5,7 +5,7 @@ def test_add_marker(): api = LSAPI() m = api.marker( - (random(), random(), random(), 0.7), p := (random() * 100, random() * 100) + (random(), random(), random(), 0.7), p := (random() * 3000, random() * 3000) ) assert list(p) == m["pos"] @@ -13,10 +13,10 @@ def test_add_marker(): def test_add_5000_markers_seq(): api = LSAPI() first = api.marker( - (random(), random(), random(), 0.7), (random() * 100, random() * 100) + (random(), random(), random(), 0.7), (random() * 3000, random() * 3000) ) col_pos = [ - ((random(), random(), random(), 0.7), (random() * 100, random() * 100)) + ((random(), random(), random(), 0.7), (random() * 3000, random() * 3000)) for _ in range(1, 5000) ] @@ -29,11 +29,11 @@ def test_add_5000_markers_seq(): def test_add_5000_markers_batch_by100(): api = LSAPI() for _ in range(50): + color = (random(), random(), random(), 0.7) first = api.marker( - (random(), random(), random(), 0.7), (random() * 100, random() * 100) + color, (random() * 3000, random() * 3000) ) - positions = [(random() * 100, random() * 100) for _ in range(1, 100)] - color = (random(), random(), random(), 0.7) + positions = [(random() * 3000, random() * 3000) for _ in range(1, 100)] markers = api.marker(color, positions) for i, m in enumerate(markers["markers"]): @@ -42,16 +42,24 @@ def test_add_5000_markers_batch_by100(): def test_add_5000_markers_in_one(): api = LSAPI() + color = (random(), random(), random(), 0.7) first = api.marker( - (random(), random(), random(), 0.7), (random() * 100, random() * 100) + color, (random() * 3000, random() * 3000) ) - positions = [(random() * 100, random() * 100) for _ in range(1, 5000)] - color = (random(), random(), random(), 0.7) + positions = [(random() * 3000, random() * 3000) for _ in range(1, 5000)] markers = api.marker(color, positions) for i, m in enumerate(markers["markers"]): assert m["id"] == (1 + i) + first["id"] +def test_get_markers() -> None: + api = LSAPI() + markers = api.markers() + assert isinstance(markers, list) + assert all(isinstance(marker, dict) for marker in markers) + assert all("id" in marker for marker in markers) + assert all("pos" in marker for marker in markers) + assert all("color" in marker for marker in markers) def test_go_next(): api = LSAPI()