1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-14 20:50:55 +02:00

Compare commits

...

13 Commits

Author SHA1 Message Date
semantic-release
5c12ab1992 2.7.0
Automatically generated by python-semantic-release
2025-05-26 12:23:35 +00:00
ce88787e88 feat(image): roi plots with crosshair cuts added 2025-05-26 14:22:51 +02:00
e12e9e534d fix(image/image_selecetion): toolbar selection tool size adjusted 2025-05-26 14:22:51 +02:00
66e9445760 fix(plot_base/mouse_interactions.py): fixed parent 2025-05-26 14:22:51 +02:00
semantic-release
6bf4c53805 2.6.0
Automatically generated by python-semantic-release
2025-05-26 11:14:14 +00:00
a939c3b1c4 feat(image_roi_tree): gui roi manager for image widget 2025-05-26 13:13:31 +02:00
41b7ca8e64 fix(image_roi): position can be set from rpc 2025-05-26 13:13:31 +02:00
7a531c17d6 refactor(image_roi): glowing handles for Rectangle roi 2025-05-26 13:13:31 +02:00
a020f2dc7e feat(waveform): LMFitDialog cleanup after close 2025-05-26 13:13:31 +02:00
53377d26e2 ci: add pr issue sync 2025-05-23 17:27:54 +02:00
05489a1c56 chore: migrate issue template to github form syntax 2025-05-22 15:48:10 +02:00
semantic-release
0dfff71e4a 2.5.4
Automatically generated by python-semantic-release
2025-05-22 10:48:11 +00:00
d4def09a4e fix(dock_area): menu to add LogPanel into DockArea is temporary disabled 2025-05-22 12:47:21 +02:00
27 changed files with 1701 additions and 147 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

41
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Bug report
description: File a bug report.
title: "[BUG]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Bug report:
- type: textarea
id: description
attributes:
label: Provide a brief description of the bug.
- type: textarea
id: expected
attributes:
label: Describe what you expected to happen and what actually happened.
- type: textarea
id: reproduction
attributes:
label: Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.
- type: input
id: version
attributes:
label: bec_widgets version
description: which version of BEC widgets was running?
- type: input
id: bec-version
attributes:
label: bec core version
description: which version of BEC core was running?
- type: textarea
id: extra
attributes:
label: Any extra info / data? e.g. log output...
- type: input
id: issues
attributes:
label: Related issues
description: please tag any related issues

View File

@@ -1,3 +1,13 @@
---
name: Documentation update request
about: Suggest an update to the docs
title: '[DOCS]: '
type: documentation
label: documentation
assignees: ''
---
## Documentation Section
[Specify the section or page of the documentation that needs updating]

View File

@@ -1,8 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
title: '[FEAT]: '
type: feature
label: feature
assignees: ''
---

View File

@@ -1,19 +1,24 @@
## Description
[Provide a brief description of the changes introduced by this merge request.]
[Provide a brief description of the changes introduced by this pull request.]
## Related Issues
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
[Cite any related issues or feature requests that are addressed or resolved by this pull request. Link the associated issue, for example, with `fixes #123` or `closes #123`.]
## Type of Change
- Change 1
- Change 2
## How to test
- Run unit tests
- Open [widget] in designer and play around with the properties
## Potential side effects
[Describe any potential side effects or risks of merging this MR.]
[Describe any potential side effects or risks of merging this PR.]
## Screenshots / GIFs (if applicable)

View File

@@ -0,0 +1,342 @@
import functools
import os
from typing import Literal
import requests
from github import Github
from pydantic import BaseModel
class GHConfig(BaseModel):
token: str
organization: str
repository: str
project_number: int
graphql_url: str
rest_url: str
headers: dict
class ProjectItemHandler:
"""
A class to handle GitHub project items.
"""
def __init__(self, gh_config: GHConfig):
self.gh_config = gh_config
self.gh = Github(gh_config.token)
self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}")
self.project_node_id = self.get_project_node_id()
def set_issue_status(
self,
status: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
issue_number: int | None = None,
issue_node_id: str | None = None,
):
"""
Set the status field of a GitHub issue in the project.
Args:
status (str): The status to set. Must be one of the predefined statuses.
issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided.
issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided.
"""
if not issue_number and not issue_node_id:
raise ValueError("Either issue_number or issue_node_id must be provided.")
if issue_number and issue_node_id:
raise ValueError("Only one of issue_number or issue_node_id must be provided.")
if issue_number is not None:
issue = self.repo.get_issue(issue_number)
issue_id = self.get_issue_info(issue.node_id)[0]["id"]
else:
issue_id = issue_node_id
field_id, option_id = self.get_status_field_id(field_name=status)
self.set_field_option(issue_id, field_id, option_id)
def run_graphql(self, query: str, variables: dict) -> dict:
"""
Execute a GraphQL query against the GitHub API.
Args:
query (str): The GraphQL query to execute.
variables (dict): The variables to pass to the query.
Returns:
dict: The response from the GitHub API.
"""
response = requests.post(
self.gh_config.graphql_url,
json={"query": query, "variables": variables},
headers=self.gh_config.headers,
timeout=10,
)
if response.status_code != 200:
raise Exception(
f"Query failed with status code {response.status_code}: {response.text}"
)
return response.json()
def get_project_node_id(self):
"""
Retrieve the project node ID from the GitHub API.
"""
query = """
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
"""
variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number}
resp = self.run_graphql(query, variables)
return resp["data"]["organization"]["projectV2"]["id"]
def get_issue_info(self, issue_node_id: str):
"""
Get the project-related information for a given issue node ID.
Args:
issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I".
Returns:
list[dict]: A list of project items associated with the issue.
"""
query = """
query($issueId: ID!) {
node(id: $issueId) {
... on Issue {
projectItems(first: 10) {
nodes {
project {
id
title
}
id
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
}
}
}
}
}
"""
variables = {"issueId": issue_node_id}
resp = self.run_graphql(query, variables)
return resp["data"]["node"]["projectItems"]["nodes"]
def get_status_field_id(
self,
field_name: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
) -> tuple[str, str]:
"""
Get the status field ID and option ID for the given field name in the project.
Args:
field_name (str): The name of the field to retrieve.
Must be one of the predefined statuses.
Returns:
tuple[str, str]: A tuple containing the field ID and option ID.
"""
field_id = None
option_id = None
project_fields = self.get_project_fields()
for field in project_fields:
if field["name"] != "Status":
continue
field_id = field["id"]
for option in field["options"]:
if option["name"] == field_name:
option_id = option["id"]
break
if not field_id or not option_id:
raise ValueError(f"Field '{field_name}' not found in project fields.")
return field_id, option_id
def set_field_option(self, item_id, field_id, option_id):
"""
Set the option of a project item for a single-select field.
Args:
item_id (str): The ID of the project item to update.
field_id (str): The ID of the field to update.
option_id (str): The ID of the option to set.
"""
mutation = """
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
"""
variables = {
"projectId": self.project_node_id,
"itemId": item_id,
"fieldId": field_id,
"optionId": option_id,
}
return self.run_graphql(mutation, variables)
@functools.lru_cache(maxsize=1)
def get_project_fields(self) -> list[dict]:
"""
Get the available fields in the project.
This method caches the result to avoid multiple API calls.
Returns:
list[dict]: A list of fields in the project.
"""
query = """
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
"""
variables = {"projectId": self.project_node_id}
resp = self.run_graphql(query, variables)
return list(filter(bool, resp["data"]["node"]["fields"]["nodes"]))
def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]:
"""
Get the linked issues of a pull request.
Args:
pr_number (int): The pull request number.
Returns:
list[dict]: A list of linked issues.
"""
query = """
query($number: Int!, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
closingIssuesReferences(first: 50) {
edges {
node {
id
body
number
title
}
}
}
}
}
}
"""
variables = {
"number": pr_number,
"owner": self.gh_config.organization,
"repo": self.gh_config.repository,
}
resp = self.run_graphql(query, variables)
edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
return [edge["node"] for edge in edges if edge.get("node")]
def main():
# GitHub settings
token = os.getenv("TOKEN")
org = os.getenv("ORG")
repo = os.getenv("REPO")
project_number = os.getenv("PROJECT_NUMBER")
pr_number = os.getenv("PR_NUMBER")
if not token:
raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.")
if not org:
raise ValueError("GitHub organization is not set. Please set the ORG environment variable.")
if not repo:
raise ValueError("GitHub repository is not set. Please set the REPO environment variable.")
if not project_number:
raise ValueError(
"GitHub project number is not set. Please set the PROJECT_NUMBER environment variable."
)
if not pr_number:
raise ValueError(
"Pull request number is not set. Please set the PR_NUMBER environment variable."
)
project_number = int(project_number)
pr_number = int(pr_number)
gh_config = GHConfig(
token=token,
organization=org,
repository=repo,
project_number=project_number,
graphql_url="https://api.github.com/graphql",
rest_url=f"https://api.github.com/repos/{org}/{repo}/issues",
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
)
project_item_handler = ProjectItemHandler(gh_config=gh_config)
# Get PR info
pr = project_item_handler.repo.get_pull(pr_number)
# Get the linked issues of the pull request
linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number)
print(f"Linked issues: {linked_issues}")
target_status = "In Development" if pr.draft else "Ready For Review"
print(f"Target status: {target_status}")
for issue in linked_issues:
project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
pydantic
pygithub

40
.github/workflows/sync-issues-pr.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Sync PR to Project
on:
pull_request:
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
jobs:
sync-project:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
env:
PROJECT_NUMBER: 3 # BEC Project
ORG: 'bec-project'
REPO: 'bec_widgets'
TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Set up python environment
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Checkout repo
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Install dependencies
run: |
pip install -r ./.github/scripts/pr_issue_sync/requirements.txt
- name: Sync PR to Project
run: |
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py

View File

@@ -1,17 +0,0 @@
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

View File

@@ -1,40 +0,0 @@
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
## Problem Description
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
## Use Case
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
## Proposed Solution
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
## Benefits
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
## Alternatives Considered
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
## Impact on Existing Functionality
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
## Priority
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
## Attachments
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

View File

@@ -1,6 +1,61 @@
# CHANGELOG
## v2.7.0 (2025-05-26)
### Bug Fixes
- **image/image_selecetion**: Toolbar selection tool size adjusted
([`e12e9e5`](https://github.com/bec-project/bec_widgets/commit/e12e9e534d6913223b741bff31bed6674ae4c0e6))
- **plot_base/mouse_interactions.py**: Fixed parent
([`66e9445`](https://github.com/bec-project/bec_widgets/commit/66e9445760f2796c008d08feba54c3d48e4a9cfb))
### Features
- **image**: Roi plots with crosshair cuts added
([`ce88787`](https://github.com/bec-project/bec_widgets/commit/ce88787e881d12384dd3a25b75fadda1f2280c81))
## v2.6.0 (2025-05-26)
### Bug Fixes
- **image_roi**: Position can be set from rpc
([`41b7ca8`](https://github.com/bec-project/bec_widgets/commit/41b7ca8e649d39dd21d09febfa8aabfc8f6f98fc))
### Chores
- Migrate issue template to github form syntax
([`05489a1`](https://github.com/bec-project/bec_widgets/commit/05489a1c563e20a49fe34d4df97ca0c3c23d8634))
### Continuous Integration
- Add pr issue sync
([`53377d2`](https://github.com/bec-project/bec_widgets/commit/53377d26e2767b3df7c788330c4d592fc12051ed))
### Features
- **image_roi_tree**: Gui roi manager for image widget
([`a939c3b`](https://github.com/bec-project/bec_widgets/commit/a939c3b1c4a7bcf1322f2d1d330fdb721ea04d56))
- **waveform**: Lmfitdialog cleanup after close
([`a020f2d`](https://github.com/bec-project/bec_widgets/commit/a020f2dc7e537493ce4aff5d88ea003956624869))
### Refactoring
- **image_roi**: Glowing handles for Rectangle roi
([`7a531c1`](https://github.com/bec-project/bec_widgets/commit/7a531c17d6a4411550600ddc8bb9d56ee777259d))
## v2.5.4 (2025-05-22)
### Bug Fixes
- **dock_area**: Menu to add LogPanel into DockArea is temporary disabled
([`d4def09`](https://github.com/bec-project/bec_widgets/commit/d4def09a4ecc024fd7e0e90fd975799066e7bb58))
## v2.5.3 (2025-05-22)
### Bug Fixes

View File

@@ -602,6 +602,16 @@ class BaseROI(RPCBase):
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
@@ -701,6 +711,16 @@ class CircularROI(RPCBase):
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class Curve(RPCBase):
@rpc_call
@@ -1418,7 +1438,7 @@ class Image(RPCBase):
self,
kind: "Literal['rect', 'circle']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 10,
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
**pg_kwargs,
@@ -2652,6 +2672,16 @@ class RectangularROI(RPCBase):
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
@rpc_call
def set_position(self, x: "float", y: "float"):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""

View File

@@ -114,7 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
#
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=False)
self.im = Image(popups=True)
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")

View File

@@ -85,7 +85,8 @@ class Crosshair(QObject):
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_2d = None
self.marker_2d_row = None
self.marker_2d_col = None
self.update_markers()
self.check_log()
self.check_derivatives()
@@ -195,13 +196,23 @@ class Crosshair(QObject):
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
if self.marker_2d_row is not None and self.marker_2d_col is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
# Create horizontal ROI for row highlighting
if item.image is None:
continue
self.marker_2d_row = pg.ROI(
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
self.marker_2d_row.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
def snap_to_data(
self, x: float, y: float
@@ -243,6 +254,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@@ -330,7 +343,10 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
@@ -384,7 +400,10 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
@@ -428,6 +447,8 @@ class Crosshair(QObject):
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
@@ -450,9 +471,12 @@ class Crosshair(QObject):
self.clear_markers()
def cleanup(self):
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)

View File

@@ -163,8 +163,11 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar",
filled=True,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
),
},
),
@@ -230,9 +233,11 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# FIXME temporarily disabled -> issue #644
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)

View File

@@ -8,13 +8,16 @@ from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QPointF, Signal
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
)
@@ -122,6 +125,7 @@ class Image(PlotBase):
"rois",
]
sync_colorbar_with_autorange = Signal()
image_updated = Signal()
def __init__(
self,
@@ -138,6 +142,8 @@ class Image(PlotBase):
self._color_bar = None
self._main_image = ImageItem()
self.roi_controller = ROIController(colormap="viridis")
self.x_roi = None
self.y_roi = None
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
@@ -149,25 +155,60 @@ class Image(PlotBase):
# Default Color map to plasma
self.color_map = "plasma"
# Headless controller keeps the canonical list.
self._roi_manager_dialog = None
# Initialize ROI plots and side panels
self._add_roi_plots()
self.roi_manager_dialog = None
# Refresh theme for ROI plots
self._update_theme()
################################################################################
# Widget Specific GUI interactions
################################################################################
def apply_theme(self, theme: str):
super().apply_theme(theme)
if self.x_roi is not None and self.y_roi is not None:
self.x_roi.apply_theme(theme)
self.y_roi.apply_theme(theme)
def _init_toolbar(self):
# add to the first position
self.selection_bundle = MonitorSelectionToolbarBundle(
bundle_id="selection", target_widget=self
)
self.toolbar.add_bundle(self.selection_bundle, self)
self.toolbar.add_bundle(bundle=self.selection_bundle, target_widget=self)
super()._init_toolbar()
# Image specific changes to PlotBase toolbar
self.toolbar.widgets["reset_legend"].action.setVisible(False)
# ROI Bundle replacement with switchable crosshair
self.toolbar.remove_bundle("roi")
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
crosshair_roi = MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=self,
)
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
crosshair.action.toggled.connect(self.toggle_crosshair)
switch_crosshair = SwitchableToolBarAction(
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
initial_action="crosshair_simple",
tooltip="Crosshair",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
)
# Lock aspect ratio button
self.lock_aspect_ratio_action = MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
@@ -216,11 +257,8 @@ class Image(PlotBase):
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="autorange_image",
action=self.autorange_switch,
target_widget=self,
self.toolbar.add_action(
action_id="autorange_image", action=self.autorange_switch, target_widget=self
)
self.autorange_mean_action.action.toggled.connect(
@@ -252,11 +290,8 @@ class Image(PlotBase):
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="switch_colorbar",
action=self.colorbar_switch,
target_widget=self,
self.toolbar.add_action(
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
)
self.simple_colorbar_action.action.toggled.connect(
@@ -266,6 +301,55 @@ class Image(PlotBase):
lambda checked: self.enable_colorbar(checked, style="full")
)
########################################
# ROI Gui Manager
def add_side_menus(self):
super().add_side_menus()
roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
self.side_panel.add_menu(
action_id="roi_mgr",
icon_name="view_list",
tooltip="ROI Manager",
widget=roi_mgr,
title="ROI Manager",
)
def add_popups(self):
super().add_popups() # keep Axis Settings
roi_action = MaterialIconAction(
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
)
# self.popup_bundle.add_action("roi_mgr", roi_action)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
)
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
def show_roi_manager_popup(self):
roi_action = self.toolbar.widgets["roi_mgr"].action
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
self.roi_manager_dialog = QDialog(modal=False)
self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
self.roi_manager_dialog.show()
roi_action.setChecked(True)
else:
self.roi_manager_dialog.raise_()
self.roi_manager_dialog.activateWindow()
roi_action.setChecked(True)
def _roi_mgr_closed(self):
self.roi_mgr.close()
self.roi_mgr.deleteLater()
self.roi_manager_dialog.close()
self.roi_manager_dialog.deleteLater()
self.roi_manager_dialog = None
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
def enable_colorbar(
self,
enabled: bool,
@@ -324,7 +408,7 @@ class Image(PlotBase):
self,
kind: Literal["rect", "circle"] = "rect",
name: str | None = None,
line_width: int | None = 10,
line_width: int | None = 5,
pos: tuple[float, float] | None = (10, 10),
size: tuple[float, float] | None = (50, 50),
**pg_kwargs,
@@ -369,6 +453,7 @@ class Image(PlotBase):
# Add to plot and controller (controller assigns color)
self.plot_item.addItem(roi)
self.roi_controller.add_roi(roi)
roi.add_scale_handle()
return roi
def remove_roi(self, roi: int | str):
@@ -380,6 +465,101 @@ class Image(PlotBase):
else:
raise ValueError("roi must be an int index or str name")
def _add_roi_plots(self):
"""
Initialize the ROI plots and side panels.
"""
# Create ROI plot widgets
self.x_roi = ImageROIPlot(parent=self)
self.y_roi = ImageROIPlot(parent=self)
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
# Set titles for the plots
self.x_roi.plot_item.setTitle("X ROI")
self.y_roi.plot_item.setTitle("Y ROI")
# Create side panels
self.side_panel_x = SidePanel(
parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
)
self.side_panel_y = SidePanel(
parent=self, orientation="left", panel_max_width=200, show_toolbar=False
)
# Add ROI plots to side panels
self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
# # Add side panels to the layout
self.layout_manager.add_widget_relative(
self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
)
self.layout_manager.add_widget_relative(
self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
)
def toggle_roi_panels(self, checked: bool):
"""
Show or hide the ROI panels based on the test action toggle state.
Args:
checked (bool): Whether the test action is checked.
"""
if checked:
# Show the ROI panels
self.hook_crosshair()
self.side_panel_x.show_panel(self.x_panel_index)
self.side_panel_y.show_panel(self.y_panel_index)
self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
self.image_updated.connect(self.update_image_slices)
else:
self.unhook_crosshair()
# Hide the ROI panels
self.side_panel_x.hide_panel()
self.side_panel_y.hide_panel()
self.image_updated.disconnect(self.update_image_slices)
@SafeSlot()
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
"""
Update the image slices based on the crosshair position.
Args:
coordinates(tuple): The coordinates of the crosshair.
"""
if coordinates is None:
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
if (
hasattr(self, "crosshair")
and hasattr(self.crosshair, "v_line")
and hasattr(self.crosshair, "h_line")
):
x = int(round(self.crosshair.v_line.value()))
y = int(round(self.crosshair.h_line.value()))
else:
return
else:
x = coordinates[1]
y = coordinates[2]
image = self._main_image.image
if image is None:
return
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
row, col = x, y
if not (0 <= row <= max_row and 0 <= col <= max_col):
return
# Horizontal slice
h_slice = image[:, col]
x_axis = np.arange(h_slice.shape[0])
self.x_roi.plot_item.clear()
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
# Vertical slice
v_slice = image[row, :]
y_axis = np.arange(v_slice.shape[0])
self.y_roi.plot_item.clear()
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
################################################################################
# Widget Specific Properties
################################################################################
@@ -934,6 +1114,7 @@ class Image(PlotBase):
self._main_image.set_data(image_buffer)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
"""
@@ -985,6 +1166,7 @@ class Image(PlotBase):
self._main_image.set_data(data)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
################################################################################
# Clean up
@@ -1031,20 +1213,38 @@ class Image(PlotBase):
self._color_bar.deleteLater()
self._color_bar = None
# Popup cleanup
if self.roi_manager_dialog is not None:
self.roi_manager_dialog.reject()
self.roi_manager_dialog = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
# ROI plots cleanup
self.x_roi.cleanup_pyqtgraph()
self.y_roi.cleanup_pyqtgraph()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QHBoxLayout
app = QApplication(sys.argv)
widget = Image(popups=True)
widget.show()
widget.resize(1000, 800)
win = QWidget()
win.setWindowTitle("Image Demo")
ml = QHBoxLayout(win)
image_popup = Image(popups=True)
image_side_panel = Image(popups=False)
ml.addWidget(image_popup)
ml.addWidget(image_side_panel)
win.resize(1500, 800)
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,37 @@
import pyqtgraph as pg
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.plots.plot_base import BECViewBox
class ImageROIPlot(RoundedFrame):
"""
A widget for displaying an image with a region of interest (ROI) overlay.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.content_widget = pg.GraphicsLayoutWidget(self)
self.layout.addWidget(self.content_widget)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.content_widget.addItem(self.plot_item)
self.curve_color = "w"
self.apply_plot_widget_style()
def apply_theme(self, theme: str):
if theme == "dark":
self.curve_color = "w"
else:
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
super().apply_theme(theme)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.plot_item.vb.menu.close()
self.plot_item.vb.menu.deleteLater()
self.plot_item.ctrlMenu.close()
self.plot_item.ctrlMenu.deleteLater()

View File

@@ -0,0 +1,375 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from bec_qthemes import material_icon
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHeaderView,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils import BECDispatcher, ConnectionConfig
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
RectangularROI,
ROIController,
)
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class ROIPropertyTree(BECWidget, QWidget):
"""
Two-column tree: [ROI] [Properties]
- Top-level: ROI name (editable) + color button.
- Children: type, line-width (spin box), coordinates (auto-updating).
Args:
image_widget (Image): The main Image widget that displays the ImageItem.
Provides ``plot_item`` and owns an ROIController already.
controller (ROIController, optional): Optionally pass an external controller.
If None, the manager uses ``image_widget.roi_controller``.
parent (QWidget, optional): Parent widget. Defaults to None.
"""
PLUGIN = False
RPC = False
COL_ACTION, COL_ROI, COL_PROPS = range(3)
DELETE_BUTTON_COLOR = "#CC181E"
def __init__(
self,
*,
parent: QWidget = None,
image_widget: Image,
controller: ROIController | None = None,
):
super().__init__(
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
)
if controller is None:
# Use the controller already belonging to the Image widget
controller = getattr(image_widget, "roi_controller", None)
if controller is None:
controller = ROIController()
image_widget.roi_controller = controller
self.image_widget = image_widget
self.plot = image_widget.plot_item
self.controller = controller
self.roi_items: dict[BaseROI, QTreeWidgetItem] = {}
self.layout = QVBoxLayout(self)
self._init_toolbar()
self._init_tree()
# connect controller
self.controller.roiAdded.connect(self._on_roi_added)
self.controller.roiRemoved.connect(self._on_roi_removed)
self.controller.cleared.connect(self.tree.clear)
# initial load
for r in self.controller.rois:
self._on_roi_added(r)
self.tree.collapseAll()
# --------------------------------------------------------------------- UI
def _init_toolbar(self):
tb = ModularToolBar(self, self, orientation="horizontal")
# --- ROI draw actions (toggleable) ---
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
tb.add_action("Add Rect ROI", self.add_rect_action, self)
tb.add_action("Add Circle ROI", self.add_circle_action, self)
# Expand/Collapse toggle
self.expand_toggle = MaterialIconAction(
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
)
tb.add_action("Expand/Collapse", self.expand_toggle, self)
def _exp_toggled(on: bool):
if on:
# switched to expanded state
self.tree.expandAll()
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
else:
# collapsed state
self.tree.collapseAll()
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
self.expand_toggle.action.setIcon(new_icon)
self.expand_toggle.action.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False)
# colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.addWidget(QWidget()) # spacer
tb.addWidget(self.cmap)
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
self.layout.addWidget(tb)
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
# ROI drawing state
self._roi_draw_mode = None # 'rect' | 'circle' | None
self._roi_start_pos = None # QPointF in image coords
self._temp_roi = None # live ROI being resized while dragging
# toggle handlers
self.add_rect_action.action.toggled.connect(
lambda on: self._set_roi_draw_mode("rect" if on else None)
)
self.add_circle_action.action.toggled.connect(
lambda on: self._set_roi_draw_mode("circle" if on else None)
)
# capture mouse events on the plot scene
self.plot.scene().installEventFilter(self)
def _init_tree(self):
self.tree = QTreeWidget()
self.tree.setColumnCount(3)
self.tree.setHeaderLabels(["Actions", "ROI", "Properties"])
self.tree.header().setSectionResizeMode(self.COL_ACTION, QHeaderView.ResizeToContents)
self.tree.headerItem().setText(self.COL_ACTION, "Actions") # blank header text
self.tree.itemChanged.connect(self._on_item_edited)
self.layout.addWidget(self.tree)
################################################################################
# Helper functions
################################################################################
# --------------------------------------------------------------------- formatting
@staticmethod
def _format_coord_text(value) -> str:
"""
Consistently format a coordinate value for display.
"""
if isinstance(value, (tuple, list)):
return "(" + ", ".join(f"{v:.2f}" for v in value) + ")"
if isinstance(value, (int, float)):
return f"{value:.2f}"
return str(value)
def _set_roi_draw_mode(self, mode: str | None):
# Ensure only the selected action is toggled on
if mode == "rect":
self.add_rect_action.action.setChecked(True)
self.add_circle_action.action.setChecked(False)
elif mode == "circle":
self.add_rect_action.action.setChecked(False)
self.add_circle_action.action.setChecked(True)
else:
self.add_rect_action.action.setChecked(False)
self.add_circle_action.action.setChecked(False)
self._roi_draw_mode = mode
self._roi_start_pos = None
# remove any unfinished temp ROI
if self._temp_roi is not None:
self.plot.removeItem(self._temp_roi)
self._temp_roi = None
def eventFilter(self, obj, event):
if self._roi_draw_mode is None:
return super().eventFilter(obj, event)
if event.type() == QEvent.GraphicsSceneMousePress and event.button() == Qt.LeftButton:
self._roi_start_pos = self.plot.vb.mapSceneToView(event.scenePos())
if self._roi_draw_mode == "rect":
self._temp_roi = RectangularROI(
pos=[self._roi_start_pos.x(), self._roi_start_pos.y()],
size=[5, 5],
parent_image=self.image_widget,
resize_handles=False,
)
if self._roi_draw_mode == "circle":
self._temp_roi = CircularROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5],
parent_image=self.image_widget,
)
self.plot.addItem(self._temp_roi)
return True
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
pos = self.plot.vb.mapSceneToView(event.scenePos())
dx = pos.x() - self._roi_start_pos.x()
dy = pos.y() - self._roi_start_pos.y()
if self._roi_draw_mode == "rect":
self._temp_roi.setSize([dx, dy])
if self._roi_draw_mode == "circle":
r = max(
1, math.hypot(dx, dy)
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
d = 2 * r # diameter
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
self._temp_roi.setSize([d, d])
return True
elif (
event.type() == QEvent.GraphicsSceneMouseRelease
and event.button() == Qt.LeftButton
and self._temp_roi is not None
):
# finalize ROI
final_roi = self._temp_roi
self._temp_roi = None
self._set_roi_draw_mode(None)
# register via controller
final_roi.add_scale_handle()
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
# parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
# --- delete button in actions column ---
del_btn = QToolButton()
delete_icon = material_icon(
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
del_btn.setIcon(delete_icon)
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
# color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
color_btn.clicked.connect(lambda: self._pick_color(roi, color_btn))
# child rows (3 columns: action, ROI, properties)
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
width_item = QTreeWidgetItem(parent, ["", "Line width", ""])
width_spin = QSpinBox()
width_spin.setRange(1, 50)
width_spin.setValue(roi.line_width)
self.tree.setItemWidget(width_item, self.COL_PROPS, width_spin)
width_spin.valueChanged.connect(lambda v, r=roi: setattr(r, "line_width", v))
# --- Step 2: Insert separate coordinate rows (one per value)
coord_rows = {}
coords = roi.get_coordinates(typed=True)
for key, value in coords.items():
# Human-readable label: “center x” from “center_x”, etc.
label = key.replace("_", " ").title()
val_text = self._format_coord_text(value)
row = QTreeWidgetItem(parent, ["", label, val_text])
coord_rows[key] = row
# keep dict refs
self.roi_items[roi] = parent
# --- Step 3: Update coordinates on ROI movement
def _update_coords():
c_dict = roi.get_coordinates(typed=True)
for k, row in coord_rows.items():
if k in c_dict:
val = c_dict[k]
row.setText(self.COL_PROPS, self._format_coord_text(val))
if isinstance(roi, RectangularROI):
roi.edgesChanged.connect(_update_coords)
else:
roi.centerChanged.connect(_update_coords)
# sync width edits back to spinbox
roi.penChanged.connect(lambda r=roi, sp=width_spin: sp.setValue(r.line_width))
roi.nameChanged.connect(lambda n, itm=parent: itm.setText(self.COL_ROI, n))
# color changes
roi.penChanged.connect(lambda r=roi, b=color_btn: b.set_color(r.line_color))
for c in range(3):
self.tree.resizeColumnToContents(c)
def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None)
if item:
idx = self.tree.indexOfTopLevelItem(item)
self.tree.takeTopLevelItem(idx)
# ---------------------------------------------------------- event handlers
def _pick_color(self, roi: BaseROI, btn: "ColorButtonNative"):
clr = QColorDialog.getColor(QColor(roi.line_color), self, "Select ROI Color")
if clr.isValid():
roi.line_color = clr.name()
btn.set_color(clr)
def _on_item_edited(self, item: QTreeWidgetItem, col: int):
if col != self.COL_ROI:
return
# find which roi
for r, it in self.roi_items.items():
if it is item:
r.label = item.text(self.COL_ROI)
break
def _delete_roi(self, roi):
self.controller.remove_roi(roi)
def cleanup(self):
self.cmap.close()
self.cmap.deleteLater()
super().cleanup()
# Demo
if __name__ == "__main__": # pragma: no cover
import sys
import numpy as np
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
from bec_widgets.widgets.plots.image.image import Image
app = QApplication(sys.argv)
bec_dispatcher = BECDispatcher(gui_id="roi_tree_demo")
client = bec_dispatcher.client
client.start()
image_widget = Image(popups=False)
image_widget.main_image.set_data(np.random.normal(size=(200, 200)))
win = QWidget()
win.setWindowTitle("Modular ROI Demo")
ml = QHBoxLayout(win)
# Add the image widget on the left
ml.addWidget(image_widget)
# ROI manager linked to that image
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
mgr.setFixedWidth(350)
ml.addWidget(mgr)
win.resize(1500, 600)
win.show()
sys.exit(app.exec_())

View File

@@ -35,19 +35,20 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setFixedWidth(150)
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False))
# 2) Dimension combo box
self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(60)
self.dim_combo_box.setFixedWidth(100)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())

View File

@@ -113,6 +113,7 @@ class BaseROI(BECConnector):
"line_width.setter",
"get_coordinates",
"get_data_from_image",
"set_position",
]
def __init__(
@@ -125,7 +126,7 @@ class BaseROI(BECConnector):
# ROI-specific
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
line_width: int = 5,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
@@ -333,7 +334,22 @@ class BaseROI(BECConnector):
def add_scale_handle(self):
return
def set_position(self, x: float, y: float):
"""
Sets the position of the ROI.
Args:
x (float): The x-coordinate of the new position.
y (float): The y-coordinate of the new position.
"""
self.setPos(x, y)
def remove(self):
# Delegate to controller first so that GUI managers stay in sync
controller = getattr(self.parent_image, "roi_controller", None)
if controller and self in controller.rois:
controller.remove_roi(self)
return # controller will call back into this method once deregistered
handles = self.handles
for i in range(len(handles)):
try:
@@ -342,9 +358,8 @@ class BaseROI(BECConnector):
continue
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
if hasattr(self.parent_image, "roi_controller"):
self.parent_image.roi_controller._rois.remove(self)
self.parent_image.roi_controller._rebuild_color_buffer()
viewBox = self.parent_plot_item.vb
viewBox.update()
class RectangularROI(BaseROI, pg.RectROI):
@@ -378,7 +393,7 @@ class RectangularROI(BaseROI, pg.RectROI):
# ROI specifics
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
line_width: int = 5,
resize_handles: bool = True,
**extra_pg,
):
@@ -414,8 +429,6 @@ class RectangularROI(BaseROI, pg.RectROI):
self.sigRegionChanged.connect(self._on_region_changed)
self.adorner = LabelAdorner(roi=self)
if resize_handles:
self.add_scale_handle()
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
@@ -440,6 +453,11 @@ class RectangularROI(BaseROI, pg.RectROI):
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
self.handleHoverBrush = (0, 255, 255, 160)
def _on_region_changed(self):
"""
Handles ROI region change events.
@@ -544,7 +562,7 @@ class CircularROI(BaseROI, pg.CircleROI):
parent_image: Image | None = None,
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
line_width: int = 5,
**extra_pg,
):
"""
@@ -725,7 +743,7 @@ class ROIController(QObject):
roi.line_color = color
# ensure line width default is at least 3 if not previously set
if getattr(roi, "line_width", 0) < 1:
roi.line_width = 10
roi.line_width = 5
self.roiAdded.emit(roi)
def remove_roi(self, roi: BaseROI):
@@ -738,8 +756,12 @@ class ROIController(QObject):
Args:
roi (BaseROI): The ROI instance to remove.
"""
rois = self._rois
if roi not in rois:
if roi in self._rois:
self.roiRemoved.emit(roi)
self._rois.remove(roi)
roi.remove()
self._rebuild_color_buffer()
else:
roi.remove()
def get_roi(self, index: int) -> BaseROI | None:
@@ -782,7 +804,7 @@ class ROIController(QObject):
"""
roi = self.get_roi(index)
if roi is not None:
roi.remove()
self.remove_roi(roi)
def remove_roi_by_name(self, name: str):
"""
@@ -793,7 +815,7 @@ class ROIController(QObject):
"""
roi = self.get_roi_by_name(name)
if roi is not None:
roi.remove()
self.remove_roi(roi)
def clear(self):
"""
@@ -803,7 +825,7 @@ class ROIController(QObject):
the cleared signal to notify listeners that all ROIs have been removed.
"""
for roi in list(self._rois):
roi.remove()
self.remove_roi(roi)
self.cleared.emit()
def renormalize_colors(self):

View File

@@ -44,7 +44,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
initial_action="drag_mode",
tooltip="Mouse Modes",
checkable=True,
parent=self,
parent=self.target_widget,
)
# Add them to the bundle

View File

@@ -414,6 +414,8 @@ class Waveform(PlotBase):
"""
Slot for when the axis settings dialog is closed.
"""
self.dap_summary.close()
self.dap_summary.deleteLater()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
self.toolbar.widgets["fit_params"].action.setChecked(False)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.5.3"
version = "2.7.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
import numpy as np
import pytest
from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture
def image_widget(qtbot, mocked_client):
"""Create an Image widget with some test data."""
widget = create_widget(qtbot, Image, client=mocked_client)
# Add a simple test image
data = np.zeros((100, 100), dtype=float)
data[20:40, 20:40] = 5 # A square region with value 5
widget.main_image.set_data(data)
yield widget
@pytest.fixture
def roi_tree(qtbot, image_widget):
"""Create an ROI property tree widget linked to the image widget."""
tree = create_widget(qtbot, ROIPropertyTree, image_widget=image_widget)
yield tree
def test_initialization(roi_tree, image_widget):
"""Test that the widget initializes correctly with the right components."""
# Check the widget has the right structure
assert roi_tree.image_widget == image_widget
assert roi_tree.plot == image_widget.plot_item
assert roi_tree.controller == image_widget.roi_controller
assert isinstance(roi_tree.roi_items, dict)
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
# Check toolbar actions
assert hasattr(roi_tree, "add_rect_action")
assert hasattr(roi_tree, "add_circle_action")
assert hasattr(roi_tree, "expand_toggle")
# Check tree view setup
assert roi_tree.tree.columnCount() == 3
assert roi_tree.tree.headerItem().text(roi_tree.COL_ACTION) == "Actions"
assert roi_tree.tree.headerItem().text(roi_tree.COL_ROI) == "ROI"
assert roi_tree.tree.headerItem().text(roi_tree.COL_PROPS) == "Properties"
def test_controller_connection(roi_tree, image_widget):
"""Test that controller signals/slots are properly connected."""
roi = image_widget.add_roi(kind="rect", name="test_roi")
# Verify that ROI was added to the tree
assert roi in roi_tree.roi_items
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 1
# Remove ROI via controller and check that it's removed from the tree
image_widget.remove_roi(0)
assert roi not in roi_tree.roi_items
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 0
def test_expand_collapse_tree(roi_tree, image_widget):
"""Test that triggering the expand action expands and collapses all ROI items in the tree."""
roi1 = image_widget.add_roi(kind="rect", name="rect1")
roi2 = image_widget.add_roi(kind="circle", name="circle1")
item1 = roi_tree.roi_items[roi1]
item2 = roi_tree.roi_items[roi2]
# Initially, items should be collapsed
assert not item1.isExpanded()
assert not item2.isExpanded()
# Trigger expand
roi_tree.expand_toggle.action.trigger()
assert item1.isExpanded()
assert item2.isExpanded()
# Trigger collapse
roi_tree.expand_toggle.action.trigger()
assert not item1.isExpanded()
assert not item2.isExpanded()
def test_roi_properties_display(roi_tree, image_widget):
"""Test that ROI properties are displayed correctly in the tree."""
# Add ROI with specific properties
roi = image_widget.add_roi(kind="rect", name="prop_test", line_width=15)
roi.line_color = "#FF0000" # bright red
# Find the tree item
item = roi_tree.roi_items[roi]
# Check property display
assert item.text(roi_tree.COL_ROI) == "prop_test"
# Find the type item (first child)
type_item = item.child(0)
assert type_item.text(roi_tree.COL_ROI) == "Type"
assert type_item.text(roi_tree.COL_PROPS) == "RectangularROI"
# Find the width item (second child)
width_item = item.child(1)
assert width_item.text(roi_tree.COL_ROI) == "Line width"
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
assert width_spin.value() == 15
def test_roi_name_edit(roi_tree, image_widget, qtbot):
"""Test editing the ROI name in the tree."""
roi = image_widget.add_roi(kind="rect", name="original_name")
item = roi_tree.roi_items[roi]
# Edit the name - simulate user editing the item
item.setFlags(item.flags() | Qt.ItemIsEditable)
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
qtbot.wait(200)
# Check the ROI name was updated
assert roi.label == "new_name"
assert item.text(roi_tree.COL_ROI) == "new_name"
def test_roi_width_edit(roi_tree, image_widget, qtbot):
"""Test editing ROI line width via spin box."""
roi = image_widget.add_roi(kind="rect", name="width_test", line_width=5)
item = roi_tree.roi_items[roi]
# Find the width spin box
width_item = item.child(1) # Second child item (index 1)
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
# Change the width
width_spin.setValue(25)
qtbot.wait(200)
# Check the ROI width was updated
assert roi.line_width == 25
def test_delete_roi_button(roi_tree, image_widget, qtbot):
"""Test that the delete button correctly removes the ROI."""
roi = image_widget.add_roi(kind="rect", name="to_delete")
item = roi_tree.roi_items[roi]
# Get the delete button
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
# Click the delete button
del_btn.click()
qtbot.wait(200)
# Verify ROI was removed
assert roi not in roi_tree.roi_items
assert roi not in image_widget.roi_controller.rois
def test_roi_color_change_from_roi(roi_tree, image_widget):
"""Test that changing the ROI color updates the tree display."""
roi = image_widget.add_roi(kind="rect", name="color_test")
item = roi_tree.roi_items[roi]
# Change the ROI color directly
roi.line_color = "#00FF00" # bright green
# Check that the color button was updated
color_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_PROPS)
assert color_btn.color == "#00FF00"
def test_colormap_change(roi_tree, image_widget):
"""Test changing the colormap affects ROI colors."""
# Add multiple ROIs
roi1 = image_widget.add_roi(kind="rect", name="r1")
roi2 = image_widget.add_roi(kind="circle", name="c1")
# Store original colors
orig_colors = [roi1.line_color, roi2.line_color]
# Change colormap to "plasma" from the color map widget
roi_tree.cmap.colormap = "plasma"
# Colors should have changed
new_colors = [roi1.line_color, roi2.line_color]
assert new_colors != orig_colors
def test_coordinates_update(roi_tree, image_widget):
"""Test that coordinates update when ROI is moved."""
# Add a rectangular ROI
roi = image_widget.add_roi(kind="rect", name="moving_roi", pos=(10, 10), size=(20, 20))
item = roi_tree.roi_items[roi]
# Find coordinate items (type and width are 0 and 1, coordinates start at 2)
coordinate_items = [item.child(i) for i in range(2, item.childCount())]
# Store initial coordinates
initial_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
# Move the ROI
roi.setPos(50, 50)
# Check that coordinates were updated
new_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
assert new_coords != initial_coords
def test_draw_mode_toggle(roi_tree, qtbot):
"""Test toggling draw modes."""
# Initially no draw mode
assert roi_tree._roi_draw_mode is None
# Toggle rect mode on
roi_tree.add_rect_action.action.toggle()
assert roi_tree._roi_draw_mode == "rect"
assert roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
# Toggle circle mode on (should turn off rect mode)
roi_tree.add_circle_action.action.toggle()
qtbot.wait(200)
assert roi_tree._roi_draw_mode == "circle"
assert not roi_tree.add_rect_action.action.isChecked()
assert roi_tree.add_circle_action.action.isChecked()
# Toggle circle mode off
roi_tree.add_circle_action.action.toggle()
assert roi_tree._roi_draw_mode is None
assert not roi_tree.add_rect_action.action.isChecked()
assert not roi_tree.add_circle_action.action.isChecked()
def test_add_roi_from_toolbar(qtbot, mocked_client):
"""Test creating ROIs using the toolbar and mouse interactions."""
# Create Image widget with ROI tree
widget = create_widget(qtbot, Image, client=mocked_client)
data = np.zeros((100, 100), dtype=float)
widget.main_image.set_data(data)
qtbot.waitExposed(widget)
roi_tree = create_widget(qtbot, ROIPropertyTree, image_widget=widget)
# Get initial ROI count
initial_roi_count = len(widget.roi_controller.rois)
# Test rectangle ROI creation
# 1. Activate rectangle drawing mode
roi_tree.add_rect_action.action.setChecked(True)
assert roi_tree._roi_draw_mode == "rect"
# Get plot widget and view
plot_item = widget.plot_item
view = plot_item.vb.scene().views()[0]
qtbot.waitExposed(view)
# Define start and end points for the ROI (in view coordinates)
start_pos = QPointF(20, 20)
end_pos = QPointF(60, 60)
# Map view coordinates to scene coordinates
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
# Map scene coordinates to widget coordinates
start_pos_widget = view.mapFromScene(start_pos_scene)
end_pos_widget = view.mapFromScene(end_pos_scene)
# Using qtbot to simulate mouse actions
# First click to start drawing
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
# Then move to end position
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
# Finally release to complete the ROI
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
# Wait for signals to process
qtbot.wait(200)
# Check that a new ROI was created
assert len(widget.roi_controller.rois) == initial_roi_count + 1
# Get the newly created ROI
new_roi = widget.roi_controller.rois[-1]
# Verify it's a rectangular ROI
assert isinstance(new_roi, RectangularROI)
# Test circle ROI creation
# Reset ROI draw mode
roi_tree.add_rect_action.action.setChecked(False)
roi_tree.add_circle_action.action.setChecked(True)
assert roi_tree._roi_draw_mode == "circle"
# Define new positions for circle ROI
start_pos = QPointF(30, 30)
end_pos = QPointF(50, 50)
# Map view coordinates to scene coordinates
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
# Map scene coordinates to widget coordinates
start_pos_widget = view.mapFromScene(start_pos_scene)
end_pos_widget = view.mapFromScene(end_pos_scene)
# Using qtbot to simulate mouse actions
# First click to start drawing
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
# Then move to end position
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
# Finally release to complete the ROI
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
# Wait for signals to process
qtbot.wait(200)
# Check that a new ROI was created
assert len(widget.roi_controller.rois) == initial_roi_count + 2
# Get the newly created ROI
new_roi = widget.roi_controller.rois[-1]
# Verify it's a circle ROI
assert isinstance(new_roi, CircularROI)

View File

@@ -3,9 +3,7 @@ from __future__ import annotations
from typing import Literal
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
@@ -38,7 +36,7 @@ def test_default_properties(bec_image_widget_with_roi):
assert roi.label.startswith("ROI")
assert roi.line_width == 10
assert roi.line_width == 5
# concrete subclass type
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
@@ -190,3 +188,20 @@ def test_roi_controller_get_roi_methods(qtbot, mocked_client):
assert controller.get_roi(1) == r2
assert controller.get_roi(99) is None
assert controller.get_roi_by_name("notfound") is None
def test_roi_set_position(bec_image_widget_with_roi):
"""Test that set_position updates the ROI position and coordinates."""
widget, roi, _ = bec_image_widget_with_roi
# Save original coordinates
orig_coords = roi.get_coordinates(typed=False)
# Move ROI by a known offset
roi.set_position(10, 15)
new_coords = roi.get_coordinates(typed=False)
# The new position should reflect the set_position call
assert new_coords != orig_coords
# The first coordinate should match the new position
if hasattr(roi, "pos"):
pos = roi.pos()
assert int(pos.x()) == 10
assert int(pos.y()) == 15

View File

@@ -168,7 +168,7 @@ def test_image_data_update_1d(qtbot, mocked_client):
def test_toolbar_actions_presence(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
assert "autorange_image" in bec_image_view.toolbar.bundles["roi"]
assert "autorange_image" in bec_image_view.toolbar.widgets
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
assert "processing" in bec_image_view.toolbar.bundles
assert "selection" in bec_image_view.toolbar.bundles
@@ -386,3 +386,100 @@ def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
with pytest.raises(RuntimeError):
roi.get_data_from_image()
##################################################
# Settings and popups
##################################################
def test_show_roi_manager_popup(qtbot, mocked_client):
"""
Verify that the ROI-manager dialog opens and closes correctly,
and that the matching toolbar icon stays in sync.
"""
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
# ROI-manager toggle is exposed via the toolbar.
assert "roi_mgr" in view.toolbar.widgets
roi_action = view.toolbar.widgets["roi_mgr"].action
assert roi_action.isChecked() is False, "Should start unchecked"
# Open the popup.
view.show_roi_manager_popup()
assert view.roi_manager_dialog is not None
assert view.roi_manager_dialog.isVisible()
assert roi_action.isChecked() is True, "Icon should toggle on"
# Close again.
view.roi_manager_dialog.close()
assert view.roi_manager_dialog is None
assert roi_action.isChecked() is False, "Icon should toggle off"
###################################
# ROI Plots & Crosshair Switch
###################################
def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
"""
Verify that enabling the ROIcrosshair shows ROI panels and disabling hides them.
"""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
# Initially panels should be hidden
assert bec_image_view.side_panel_x.panel_height == 0
assert bec_image_view.side_panel_y.panel_width == 0
# Enable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels must be visible
assert bec_image_view.side_panel_x.panel_height > 0
assert bec_image_view.side_panel_y.panel_width > 0
# Disable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels hidden again
assert bec_image_view.side_panel_x.panel_height == 0
assert bec_image_view.side_panel_y.panel_width == 0
def test_roi_plot_data_from_image(qtbot, mocked_client):
"""
Check that ROI plots receive correct slice data from the 2D image.
"""
import numpy as np
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
# Provide deterministic 2D data
test_data = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
# Activate ROI crosshair
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
# Simulate crosshair at row 2, col 3
bec_image_view.update_image_slices((0, 2, 3))
# Extract plotted data
x_items = bec_image_view.x_roi.plot_item.listDataItems()
y_items = bec_image_view.y_roi.plot_item.listDataItems()
assert len(x_items) == 1
assert len(y_items) == 1
# Vertical slice (column)
_, v_slice = x_items[0].getData()
np.testing.assert_array_equal(v_slice, test_data[:, 3])
# Horizontal slice (row)
h_slice, _ = y_items[0].getData()
np.testing.assert_array_equal(h_slice, test_data[2])