mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-15 13:10:54 +02:00
Compare commits
36 Commits
ci/fix_bui
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf4c53805 | ||
| a939c3b1c4 | |||
| 41b7ca8e64 | |||
| 7a531c17d6 | |||
| a020f2dc7e | |||
| 53377d26e2 | |||
| 05489a1c56 | |||
|
|
0dfff71e4a | ||
| d4def09a4e | |||
|
|
713653a4a5 | ||
| bcab66b187 | |||
| a345253c6e | |||
|
|
bdf33a5249 | ||
| f8276f0224 | |||
| 8227c44c33 | |||
|
|
83098d930c | ||
| a7ae856c8f | |||
|
|
06f43e4883 | ||
|
|
5ec9697271 | ||
|
|
41296b5471 | ||
| 1d018e863c | |||
| 6ee0f5004d | |||
|
|
40b5081632 | ||
| f064baae68 | |||
|
|
58f01fb3a2 | ||
| 1e344eacb7 | |||
|
|
34002fa51a | ||
| a00d510a75 | |||
|
|
120faf9523 | ||
| d7bd61f69e | |||
| 94bcfff724 | |||
| a17e7a0d52 | |||
| 7f67d28887 | |||
| 52d8e4b332 | |||
| dea2b44e6a | |||
| dc70ea6dfb |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
@@ -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]
|
||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,8 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
title: '[FEAT]: '
|
||||
type: feature
|
||||
label: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
64
.github/actions/bw_install/action.yml
vendored
Normal file
64
.github/actions/bw_install/action.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: "BEC Widgets Install"
|
||||
description: "Install BEC Widgets and related os dependencies"
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Widgets to install"
|
||||
BEC_CORE_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Core to install"
|
||||
OPHYD_DEVICES_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
PYTHON_VERSION: # id of input
|
||||
required: false
|
||||
default: "3.11"
|
||||
description: "Python version to use"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.PYTHON_VERSION }}
|
||||
|
||||
- name: Checkout BEC Core
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
path: ./bec
|
||||
|
||||
- name: Checkout Ophyd Devices
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/ophyd_devices
|
||||
ref: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
path: ./ophyd_devices
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
path: ./bec_widgets
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
@@ -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)
|
||||
|
||||
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal 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()
|
||||
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pydantic
|
||||
pygithub
|
||||
2
.github/workflows/check_pr.yml
vendored
2
.github/workflows/check_pr.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
id: script
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'push' && github.event.ref_type != 'tag'
|
||||
with:
|
||||
script: |
|
||||
const prs = await github.rest.pulls.list({
|
||||
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -1,5 +1,21 @@
|
||||
name: Full CI
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -17,6 +33,10 @@ jobs:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -24,6 +44,10 @@ jobs:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
|
||||
57
.github/workflows/pytest-matrix.yml
vendored
57
.github/workflows/pytest-matrix.yml
vendored
@@ -1,5 +1,26 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on: [workflow_call]
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
pytest-matrix:
|
||||
@@ -9,7 +30,7 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
@@ -17,30 +38,20 @@ jobs:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e ./bec/bec_lib[dev]
|
||||
pip install -e ./bec/bec_ipython_client
|
||||
pip install -e .[dev,pyside6]
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
|
||||
52
.github/workflows/pytest.yml
vendored
52
.github/workflows/pytest.yml
vendored
@@ -6,6 +6,21 @@ on:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
@@ -20,38 +35,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e ./bec/bec_lib[dev]
|
||||
pip install -e ./bec/bec_ipython_client
|
||||
pip install -e .[dev,pyside6]
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
- name: Run Pytest with Coverage
|
||||
id: coverage
|
||||
|
||||
75
.github/workflows/semantic_release.yml
vendored
75
.github/workflows/semantic_release.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
# Note: We checkout the repository at the branch that triggered the workflow
|
||||
# with the entire history to ensure to match PSR's release branch detection
|
||||
@@ -38,6 +41,13 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
|
||||
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup | Force release branch to be at workflow sha
|
||||
run: |
|
||||
@@ -81,60 +91,13 @@ jobs:
|
||||
|
||||
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
|
||||
|
||||
- name: Setup | Install Dependencies
|
||||
run: |
|
||||
pip install build wheel
|
||||
|
||||
- name: Action | Semantic Version Release
|
||||
- name: Semantic Version Release
|
||||
id: release
|
||||
# Adjust tag with desired version if applicable.
|
||||
uses: python-semantic-release/python-semantic-release@v9.21.1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
git_committer_name: "github-actions"
|
||||
git_committer_email: "actions@users.noreply.github.com"
|
||||
- name: Publish | Upload to GitHub Release Assets
|
||||
uses: python-semantic-release/publish-action@v9.21.1
|
||||
if: steps.release.outputs.released == 'true'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
|
||||
- name: Upload | Distribution Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: distribution-artifacts
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
|
||||
pypi-publish:
|
||||
# 1. Separate out the deploy step from the publish step to run each step at
|
||||
# the least amount of token privilege
|
||||
# 2. Also, deployments can fail, and its better to have a separate job if you need to retry
|
||||
# and it won't require reversing the release.
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.released == 'true' }}
|
||||
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/bec-widgets
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Setup | Download Build Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
id: artifact-download
|
||||
with:
|
||||
name: distribution-artifacts
|
||||
path: dist
|
||||
|
||||
# see https://docs.pypi.org/trusted-publishers/
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.12.4
|
||||
with:
|
||||
packages-dir: dist
|
||||
print-hash: true
|
||||
verbose: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pip install python-semantic-release==9.* wheel build twine
|
||||
semantic-release -vv version
|
||||
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
|
||||
semantic-release publish
|
||||
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
Normal file
40
.github/workflows/sync-issues-pr.yml
vendored
Normal 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,6 +64,9 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
@@ -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.]
|
||||
@@ -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]
|
||||
|
||||
@@ -7,13 +7,13 @@ version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
python: "3.11"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
configuration: docs/conf.py
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
# formats:
|
||||
@@ -21,5 +21,7 @@ sphinx:
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .[dev]
|
||||
|
||||
3353
CHANGELOG.md
3353
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -504,6 +504,224 @@ class BECStatusBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BaseROI(RPCBase):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self):
|
||||
"""
|
||||
Gets the coordinates that define this ROI's position and shape.
|
||||
|
||||
This is an abstract method that must be implemented by subclasses.
|
||||
Implementations should return either a dictionary with descriptive keys
|
||||
or a tuple of coordinates, depending on the value of self.description.
|
||||
|
||||
Returns:
|
||||
dict or tuple: The coordinates defining the ROI's position and shape.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: This method must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
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."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Calculates and returns the coordinates and size of an object, either as a
|
||||
typed dictionary or as a tuple.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
|
||||
to None, which utilizes the object's description value.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
|
||||
if `typed` is True.
|
||||
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
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
|
||||
def remove(self):
|
||||
@@ -1215,6 +1433,44 @@ class Image(RPCBase):
|
||||
Access the main image item.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_roi(
|
||||
self,
|
||||
kind: "Literal['rect', 'circle']" = "rect",
|
||||
name: "str | None" = None,
|
||||
line_width: "int | None" = 5,
|
||||
pos: "tuple[float, float] | None" = (10, 10),
|
||||
size: "tuple[float, float] | None" = (50, 50),
|
||||
**pg_kwargs,
|
||||
) -> "RectangularROI | CircularROI":
|
||||
"""
|
||||
Add a ROI to the image.
|
||||
|
||||
Args:
|
||||
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
||||
name(str): The name of the ROI.
|
||||
line_width(int): The line width of the ROI.
|
||||
pos(tuple): The position of the ROI.
|
||||
size(tuple): The size of the ROI.
|
||||
**pg_kwargs: Additional arguments for the ROI.
|
||||
|
||||
Returns:
|
||||
RectangularROI | CircularROI: The created ROI object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_roi(self, roi: "int | str"):
|
||||
"""
|
||||
Remove an ROI by index or label via the ROIController.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rois(self) -> "list[BaseROI]":
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
|
||||
|
||||
class ImageItem(RPCBase):
|
||||
@property
|
||||
@@ -2318,6 +2574,115 @@ class PositionerGroup(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
descriptive keys. If False, returns them as a tuple. Defaults to
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
# "im": self.im,
|
||||
"im": self.im,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
# tab_widget.setCurrentIndex(4)
|
||||
#
|
||||
# sixth_tab = QWidget()
|
||||
# sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
# self.im = Image()
|
||||
# self.mi = self.im.main_image
|
||||
# sixth_tab_layout.addWidget(self.im)
|
||||
# tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
# tab_widget.setCurrentIndex(5)
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
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")
|
||||
tab_widget.setCurrentIndex(1)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
|
||||
@@ -2,6 +2,7 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
@@ -34,6 +35,9 @@ class UILoader:
|
||||
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
|
||||
plugin_widgets = get_all_plugin_widgets()
|
||||
self.custom_widgets.update(plugin_widgets)
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
elif PYQT6:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,18 +8,25 @@ 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.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
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,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -111,6 +118,9 @@ class Image(PlotBase):
|
||||
"transpose.setter",
|
||||
"image",
|
||||
"main_image",
|
||||
"add_roi",
|
||||
"remove_roi",
|
||||
"rois",
|
||||
]
|
||||
sync_colorbar_with_autorange = Signal()
|
||||
|
||||
@@ -128,6 +138,7 @@ class Image(PlotBase):
|
||||
self.gui_id = config.gui_id
|
||||
self._color_bar = None
|
||||
self._main_image = ImageItem()
|
||||
self.roi_controller = ROIController(colormap="viridis")
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
@@ -139,6 +150,8 @@ class Image(PlotBase):
|
||||
# Default Color map to plasma
|
||||
self.color_map = "plasma"
|
||||
|
||||
self.roi_manager_dialog = None
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
@@ -253,6 +266,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,
|
||||
@@ -304,9 +366,82 @@ class Image(PlotBase):
|
||||
if vrange: # should be at the end to disable the autorange if defined
|
||||
self.v_range = vrange
|
||||
|
||||
################################################################################
|
||||
# Static rois with roi manager
|
||||
|
||||
def add_roi(
|
||||
self,
|
||||
kind: Literal["rect", "circle"] = "rect",
|
||||
name: str | None = None,
|
||||
line_width: int | None = 5,
|
||||
pos: tuple[float, float] | None = (10, 10),
|
||||
size: tuple[float, float] | None = (50, 50),
|
||||
**pg_kwargs,
|
||||
) -> RectangularROI | CircularROI:
|
||||
"""
|
||||
Add a ROI to the image.
|
||||
|
||||
Args:
|
||||
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
||||
name(str): The name of the ROI.
|
||||
line_width(int): The line width of the ROI.
|
||||
pos(tuple): The position of the ROI.
|
||||
size(tuple): The size of the ROI.
|
||||
**pg_kwargs: Additional arguments for the ROI.
|
||||
|
||||
Returns:
|
||||
RectangularROI | CircularROI: The created ROI object.
|
||||
"""
|
||||
if name is None:
|
||||
name = f"ROI_{len(self.roi_controller.rois) + 1}"
|
||||
if kind == "rect":
|
||||
roi = RectangularROI(
|
||||
pos=pos,
|
||||
size=size,
|
||||
parent_image=self,
|
||||
line_width=line_width,
|
||||
label=name,
|
||||
**pg_kwargs,
|
||||
)
|
||||
elif kind == "circle":
|
||||
roi = CircularROI(
|
||||
pos=pos,
|
||||
size=size,
|
||||
parent_image=self,
|
||||
line_width=line_width,
|
||||
label=name,
|
||||
**pg_kwargs,
|
||||
)
|
||||
else:
|
||||
raise ValueError("kind must be 'rect' or 'circle'")
|
||||
|
||||
# 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):
|
||||
"""Remove an ROI by index or label via the ROIController."""
|
||||
if isinstance(roi, int):
|
||||
self.roi_controller.remove_roi_by_index(roi)
|
||||
elif isinstance(roi, str):
|
||||
self.roi_controller.remove_roi_by_name(roi)
|
||||
else:
|
||||
raise ValueError("roi must be an int index or str name")
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
################################################################################
|
||||
# Rois
|
||||
|
||||
@property
|
||||
def rois(self) -> list[BaseROI]:
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
return self.roi_controller.rois
|
||||
|
||||
################################################################################
|
||||
# Colorbar toggle
|
||||
@@ -925,6 +1060,11 @@ class Image(PlotBase):
|
||||
"""
|
||||
Disconnect the image update signals and clean up the image.
|
||||
"""
|
||||
# Remove all ROIs
|
||||
rois = self.rois
|
||||
for roi in rois:
|
||||
roi.remove()
|
||||
|
||||
# Main Image cleanup
|
||||
if self._main_image.config.monitor is not None:
|
||||
self.disconnect_monitor(self._main_image.config.monitor)
|
||||
@@ -941,6 +1081,11 @@ 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()
|
||||
@@ -951,10 +1096,19 @@ class Image(PlotBase):
|
||||
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_())
|
||||
|
||||
@@ -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_())
|
||||
0
bec_widgets/widgets/plots/roi/__init__.py
Normal file
0
bec_widgets/widgets/plots/roi/__init__.py
Normal file
889
bec_widgets/widgets/plots/roi/image_roi.py
Normal file
889
bec_widgets/widgets/plots/roi/image_roi.py
Normal file
@@ -0,0 +1,889 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import TextItem
|
||||
from pyqtgraph import functions as fn
|
||||
from pyqtgraph import mkPen
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
class LabelAdorner:
|
||||
"""Manages a TextItem label on top of any ROI, keeping it aligned."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
roi: BaseROI,
|
||||
anchor: tuple[int, int] = (0, 1),
|
||||
padding: int = 2,
|
||||
bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
|
||||
text_color: str | tuple[int, int, int, int] = "white",
|
||||
):
|
||||
"""
|
||||
Initializes a label overlay for a given region of interest (ROI), allowing for customization
|
||||
of text placement, padding, background color, and text color. Automatically attaches the label
|
||||
to the ROI and updates its position and content based on ROI changes.
|
||||
|
||||
Args:
|
||||
roi: The region of interest to which the label will be attached.
|
||||
anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
|
||||
padding: Integer specifying the padding around the label's text. Default is 2.
|
||||
bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
|
||||
text_color: String specifying the color of the label's text. Default is "white".
|
||||
"""
|
||||
self.roi = roi
|
||||
self.label = TextItem(anchor=anchor)
|
||||
self.padding = padding
|
||||
self.bg_rgba = bg_color
|
||||
self.text_color = text_color
|
||||
roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
|
||||
# initial draw
|
||||
self._update_html(roi.label)
|
||||
self._reposition()
|
||||
# reconnect on geometry/name changes
|
||||
roi.sigRegionChanged.connect(self._reposition)
|
||||
if hasattr(roi, "nameChanged"):
|
||||
roi.nameChanged.connect(self._update_html)
|
||||
|
||||
def _update_html(self, text: str):
|
||||
"""
|
||||
Updates the HTML content of the label with the given text.
|
||||
|
||||
Creates an HTML div with the configured background color, text color, and padding,
|
||||
then sets this HTML as the content of the label.
|
||||
|
||||
Args:
|
||||
text (str): The text to display in the label.
|
||||
"""
|
||||
html = (
|
||||
f'<div style="background: rgba{self.bg_rgba}; '
|
||||
f"font-weight:bold; color:{self.text_color}; "
|
||||
f'padding:{self.padding}px;">{text}</div>'
|
||||
)
|
||||
self.label.setHtml(html)
|
||||
|
||||
def _reposition(self):
|
||||
"""
|
||||
Repositions the label to align with the ROI's current position.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It places the label at the bottom-left corner of the ROI's bounding rectangle.
|
||||
"""
|
||||
# put at top-left corner of ROI’s bounding rect
|
||||
size = self.roi.state["size"]
|
||||
height = size[1]
|
||||
self.label.setPos(0, height)
|
||||
|
||||
|
||||
class BaseROI(BECConnector):
|
||||
"""Base class for all Region of Interest (ROI) implementations.
|
||||
|
||||
This class serves as a mixin that provides common properties and methods for ROIs,
|
||||
including name, line color, and line width properties. It inherits from BECConnector
|
||||
to enable remote procedure call functionality.
|
||||
|
||||
Attributes:
|
||||
RPC (bool): Flag indicating if remote procedure calls are enabled.
|
||||
PLUGIN (bool): Flag indicating if this class is a plugin.
|
||||
nameChanged (Signal): Signal emitted when the ROI name changes.
|
||||
penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
|
||||
USER_ACCESS (list): List of methods and properties accessible via RPC.
|
||||
"""
|
||||
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
|
||||
nameChanged = Signal(str)
|
||||
penChanged = Signal()
|
||||
USER_ACCESS = [
|
||||
"label",
|
||||
"label.setter",
|
||||
"line_color",
|
||||
"line_color.setter",
|
||||
"line_width",
|
||||
"line_width.setter",
|
||||
"get_coordinates",
|
||||
"get_data_from_image",
|
||||
"set_position",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
# BECConnector kwargs
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None,
|
||||
# ROI-specific
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
# all remaining pg.*ROI kwargs (pos, size, pen, …)
|
||||
**pg_kwargs,
|
||||
):
|
||||
"""Base class for all modular ROIs.
|
||||
|
||||
Args:
|
||||
label (str): Human-readable name shown in ROI Manager and labels.
|
||||
line_color (str | None, optional): Initial pen color. Defaults to None.
|
||||
Controller may override color later.
|
||||
line_width (int, optional): Initial pen width. Defaults to 15.
|
||||
Controller may override width later.
|
||||
config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
"""
|
||||
if config is None:
|
||||
config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
|
||||
self.set_parent(parent_image)
|
||||
self.parent_plot_item = parent_image.plot_item
|
||||
object_name = label.replace("-", "_").replace(" ", "_") if label else None
|
||||
super().__init__(
|
||||
object_name=object_name, config=config, gui_id=gui_id, removable=True, **pg_kwargs
|
||||
)
|
||||
|
||||
self._label = label or "ROI"
|
||||
self._line_color = line_color or "#ffffff"
|
||||
self._line_width = line_width
|
||||
self._description = True
|
||||
self.setPen(mkPen(self._line_color, width=self._line_width))
|
||||
|
||||
def set_parent(self, parent: Image):
|
||||
"""
|
||||
Sets the parent image for this ROI.
|
||||
|
||||
Args:
|
||||
parent (Image): The parent image object to associate with this ROI.
|
||||
"""
|
||||
self.parent_image = parent
|
||||
|
||||
def parent(self):
|
||||
"""
|
||||
Gets the parent image associated with this ROI.
|
||||
|
||||
Returns:
|
||||
Image: The parent image object, or None if no parent is set.
|
||||
"""
|
||||
return self.parent_image
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
return self._label
|
||||
|
||||
@label.setter
|
||||
def label(self, new: str):
|
||||
"""
|
||||
Sets the display name of this ROI.
|
||||
|
||||
If the new name is different from the current name, this method updates
|
||||
the internal name, emits the nameChanged signal, and updates the object name.
|
||||
|
||||
Args:
|
||||
new (str): The new name to set for the ROI.
|
||||
"""
|
||||
if new != self._label:
|
||||
self._label = new
|
||||
self.nameChanged.emit(new)
|
||||
self.change_object_name(new)
|
||||
|
||||
@property
|
||||
def line_color(self) -> str:
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
return self._line_color
|
||||
|
||||
@line_color.setter
|
||||
def line_color(self, value: str):
|
||||
"""
|
||||
Sets the line color of the ROI.
|
||||
|
||||
If the new color is different from the current color, this method updates
|
||||
the internal color value, updates the pen while preserving the line width,
|
||||
and emits the penChanged signal.
|
||||
|
||||
Args:
|
||||
value (str): The new color to set for the ROI's outline (e.g., hex color code).
|
||||
"""
|
||||
if value != self._line_color:
|
||||
self._line_color = value
|
||||
# update pen but preserve width
|
||||
self.setPen(mkPen(value, width=self._line_width))
|
||||
self.penChanged.emit()
|
||||
|
||||
@property
|
||||
def line_width(self) -> int:
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
return self._line_width
|
||||
|
||||
@line_width.setter
|
||||
def line_width(self, value: int):
|
||||
"""
|
||||
Sets the line width of the ROI.
|
||||
|
||||
If the new width is different from the current width and is greater than 0,
|
||||
this method updates the internal width value, updates the pen while preserving
|
||||
the line color, and emits the penChanged signal.
|
||||
|
||||
Args:
|
||||
value (int): The new width to set for the ROI's outline in pixels.
|
||||
Must be greater than 0.
|
||||
"""
|
||||
if value != self._line_width and value > 0:
|
||||
self._line_width = value
|
||||
self.setPen(mkPen(self._line_color, width=value))
|
||||
self.penChanged.emit()
|
||||
|
||||
@property
|
||||
def description(self) -> bool:
|
||||
"""
|
||||
Gets whether ROI coordinates should be emitted with descriptive keys by default.
|
||||
|
||||
Returns:
|
||||
bool: True if coordinates should include descriptive keys, False otherwise.
|
||||
"""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: bool):
|
||||
"""
|
||||
Sets whether ROI coordinates should be emitted with descriptive keys by default.
|
||||
|
||||
This affects the default behavior of the get_coordinates method.
|
||||
|
||||
Args:
|
||||
value (bool): True to emit coordinates with descriptive keys, False to emit
|
||||
as a simple tuple of values.
|
||||
"""
|
||||
self._description = value
|
||||
|
||||
def get_coordinates(self):
|
||||
"""
|
||||
Gets the coordinates that define this ROI's position and shape.
|
||||
|
||||
This is an abstract method that must be implemented by subclasses.
|
||||
Implementations should return either a dictionary with descriptive keys
|
||||
or a tuple of coordinates, depending on the value of self.description.
|
||||
|
||||
Returns:
|
||||
dict or tuple: The coordinates defining the ROI's position and shape.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: This method must be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_coordinates()")
|
||||
|
||||
def get_data_from_image(
|
||||
self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
|
||||
):
|
||||
"""Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
if image_item is None:
|
||||
image_item = next(
|
||||
(
|
||||
it
|
||||
for it in self.scene().items()
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if image_item is None:
|
||||
raise RuntimeError("No ImageItem found in the current scene.")
|
||||
|
||||
data = image_item.image # the raw ndarray held by ImageItem
|
||||
return self.getArrayRegion(
|
||||
data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
|
||||
)
|
||||
|
||||
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:
|
||||
self.removeHandle(0)
|
||||
except IndexError:
|
||||
continue
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.parent_image.plot_item.removeItem(self)
|
||||
viewBox = self.parent_plot_item.vb
|
||||
viewBox.update()
|
||||
|
||||
|
||||
class RectangularROI(BaseROI, pg.RectROI):
|
||||
"""
|
||||
Defines a rectangular Region of Interest (ROI) with additional functionality.
|
||||
|
||||
Provides tools for manipulating and extracting data from rectangular areas on
|
||||
images, includes support for GUI features and event-driven signaling.
|
||||
|
||||
Attributes:
|
||||
edgesChanged (Signal): Signal emitted when the ROI edges change, providing
|
||||
the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
|
||||
edgesReleased (Signal): Signal emitted when the ROI edges are released,
|
||||
providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
|
||||
"""
|
||||
|
||||
edgesChanged = Signal(float, float, float, float)
|
||||
edgesReleased = Signal(float, float, float, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
# pg.RectROI kwargs
|
||||
pos: tuple[float, float],
|
||||
size: tuple[float, float],
|
||||
pen=None,
|
||||
# BECConnector kwargs
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None = None,
|
||||
# ROI specifics
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
resize_handles: bool = True,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
Initializes an instance with properties for defining a rectangular ROI with handles,
|
||||
configurations, and an auto-aligning label. Also connects a signal for region updates.
|
||||
|
||||
Args:
|
||||
pos: Initial position of the ROI.
|
||||
size: Initial size of the ROI.
|
||||
pen: Defines the border appearance; can be color or style.
|
||||
config: Optional configuration details for the connection.
|
||||
gui_id: Optional identifier for the associated GUI element.
|
||||
parent_image: Optional parent object the ROI is related to.
|
||||
label: Optional label for identification within the context.
|
||||
line_color: Optional color of the ROI outline.
|
||||
line_width: Width of the ROI's outline in pixels.
|
||||
parent_plot_item: The plot item this ROI belongs to.
|
||||
**extra_pg: Additional keyword arguments specific to pg.RectROI.
|
||||
"""
|
||||
super().__init__(
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_image=parent_image,
|
||||
label=label,
|
||||
line_color=line_color,
|
||||
line_width=line_width,
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
**extra_pg,
|
||||
)
|
||||
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self.adorner = LabelAdorner(roi=self)
|
||||
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
|
||||
self.handleHoverPen = fn.mkPen("lime", width=4)
|
||||
|
||||
def add_scale_handle(self):
|
||||
"""
|
||||
Add scale handles at every corner and edge of the ROI.
|
||||
|
||||
Corner handles are anchored to the centre for two-axis scaling.
|
||||
Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
|
||||
"""
|
||||
centre = [0.5, 0.5]
|
||||
|
||||
# Corner handles – anchored to the centre for two-axis scaling
|
||||
self.addScaleHandle([0, 0], centre) # top‑left
|
||||
self.addScaleHandle([1, 0], centre) # top‑right
|
||||
self.addScaleHandle([0, 1], centre) # bottom‑left
|
||||
self.addScaleHandle([1, 1], centre) # bottom‑right
|
||||
|
||||
# Edge handles – anchored to the midpoint of the opposite edge
|
||||
self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
|
||||
self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
|
||||
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.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It calculates the new corner coordinates and emits the edgesChanged signal
|
||||
with the updated coordinates.
|
||||
"""
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
|
||||
viewBox = self.parent_plot_item.vb
|
||||
viewBox.update()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""
|
||||
Handles mouse drag events on the ROI.
|
||||
|
||||
This method extends the parent class implementation to emit the edgesReleased
|
||||
signal when the mouse drag is finished, providing the final coordinates of the ROI.
|
||||
|
||||
Args:
|
||||
ev: The mouse event object containing information about the drag operation.
|
||||
"""
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
descriptive keys. If False, returns them as a tuple. Defaults to
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
x1, y1 = x0 + w, y0 + h
|
||||
if typed:
|
||||
return {
|
||||
"bottom_left": (x0, y0),
|
||||
"bottom_right": (x1, y0),
|
||||
"top_left": (x0, y1),
|
||||
"top_right": (x1, y1),
|
||||
}
|
||||
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
Searches for an image in the current scene.
|
||||
|
||||
This helper method iterates through all items in the scene and returns
|
||||
the first pg.ImageItem that has a non-None image property.
|
||||
|
||||
Returns:
|
||||
numpy.ndarray or None: The image from the first found ImageItem,
|
||||
or None if no suitable image is found.
|
||||
"""
|
||||
for it in self.scene().items():
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None:
|
||||
return it.image
|
||||
return None
|
||||
|
||||
|
||||
class CircularROI(BaseROI, pg.CircleROI):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling.
|
||||
|
||||
This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
|
||||
that emits signals when its center or diameter changes, and includes an auto-aligning
|
||||
label for visual identification.
|
||||
|
||||
Attributes:
|
||||
centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
|
||||
providing the new (center_x, center_y, diameter) values.
|
||||
centerReleased (Signal): Signal emitted when the ROI is released after dragging,
|
||||
providing the final (center_x, center_y, diameter) values.
|
||||
"""
|
||||
|
||||
centerChanged = Signal(float, float, float)
|
||||
centerReleased = Signal(float, float, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pos,
|
||||
size,
|
||||
pen=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None = None,
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
Initializes a circular ROI with the specified properties.
|
||||
|
||||
Creates a circular ROI at the given position and with the given size,
|
||||
connects signals for tracking changes, and attaches an auto-aligning label.
|
||||
|
||||
Args:
|
||||
pos: Initial position of the ROI as [x, y].
|
||||
size: Initial size of the ROI as [diameter, diameter].
|
||||
pen: Defines the border appearance; can be color or style.
|
||||
config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
|
||||
gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
|
||||
parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
|
||||
label (str | None, optional): Display name for the ROI. Defaults to None.
|
||||
line_color (str | None, optional): Color of the ROI outline. Defaults to None.
|
||||
line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
|
||||
parent_plot_item: The plot item this ROI belongs to.
|
||||
**extra_pg: Additional keyword arguments for pg.CircleROI.
|
||||
"""
|
||||
super().__init__(
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_image=parent_image,
|
||||
label=label,
|
||||
line_color=line_color,
|
||||
line_width=line_width,
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
**extra_pg,
|
||||
)
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self._adorner = LabelAdorner(self)
|
||||
|
||||
def _on_region_changed(self):
|
||||
"""
|
||||
Handles ROI region change events.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It calculates the center coordinates and diameter of the circle and
|
||||
emits the centerChanged signal with these values.
|
||||
"""
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
self.centerChanged.emit(cx, cy, d)
|
||||
viewBox = self.parent_plot_item.getViewBox()
|
||||
viewBox.update()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""
|
||||
Handles mouse drag events on the ROI.
|
||||
|
||||
This method extends the parent class implementation to emit the centerReleased
|
||||
signal when the mouse drag is finished, providing the final center coordinates
|
||||
and diameter of the circular ROI.
|
||||
|
||||
Args:
|
||||
ev: The mouse event object containing information about the drag operation.
|
||||
"""
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
self.centerReleased.emit(cx, cy, d)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Calculates and returns the coordinates and size of an object, either as a
|
||||
typed dictionary or as a tuple.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
|
||||
to None, which utilizes the object's description value.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
|
||||
if `typed` is True.
|
||||
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
|
||||
if typed:
|
||||
return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
|
||||
return (cx, cy, d, d / 2)
|
||||
|
||||
def _lookup_scene_image(self) -> pg.ImageItem | None:
|
||||
"""
|
||||
Retrieves an image from the scene items if available.
|
||||
|
||||
Iterates over all items in the scene and checks if any of them are of type
|
||||
`pg.ImageItem` and have a non-None image. If such an item is found, its image
|
||||
is returned.
|
||||
|
||||
Returns:
|
||||
pg.ImageItem or None: The first found ImageItem with a non-None image,
|
||||
or None if no suitable image is found.
|
||||
"""
|
||||
for it in self.scene().items():
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None:
|
||||
return it.image
|
||||
return None
|
||||
|
||||
|
||||
class ROIController(QObject):
|
||||
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
|
||||
|
||||
Handles creating, adding, removing, and managing ROI instances. Supports color assignment
|
||||
from a colormap, and provides utility methods to access and manipulate ROIs.
|
||||
|
||||
Attributes:
|
||||
roiAdded (Signal): Emits the new ROI instance when added.
|
||||
roiRemoved (Signal): Emits the removed ROI instance when deleted.
|
||||
cleared (Signal): Emits when all ROIs are removed.
|
||||
paletteChanged (Signal): Emits the new colormap name when updated.
|
||||
_colormap (str): Name of the colormap used for ROI colors.
|
||||
_rois (list[BaseROI]): Internal list storing currently managed ROIs.
|
||||
_colors (list[str]): Internal list of colors for the ROIs.
|
||||
"""
|
||||
|
||||
roiAdded = Signal(object) # emits the new ROI instance
|
||||
roiRemoved = Signal(object) # emits the removed ROI instance
|
||||
cleared = Signal() # emits when all ROIs are removed
|
||||
paletteChanged = Signal(str) # emits new colormap name
|
||||
|
||||
def __init__(self, colormap="viridis"):
|
||||
"""
|
||||
Initializes the ROI controller with the specified colormap.
|
||||
|
||||
Sets up internal data structures for managing ROIs and their colors.
|
||||
|
||||
Args:
|
||||
colormap (str, optional): The name of the colormap to use for ROI colors.
|
||||
Defaults to "viridis".
|
||||
"""
|
||||
super().__init__()
|
||||
self._colormap = colormap
|
||||
self._rois: list[BaseROI] = []
|
||||
self._colors: list[str] = []
|
||||
self._rebuild_color_buffer()
|
||||
|
||||
def _rebuild_color_buffer(self):
|
||||
"""
|
||||
Regenerates the color buffer for ROIs.
|
||||
|
||||
This internal method creates a new list of colors based on the current colormap
|
||||
and the number of ROIs. It ensures there's always one more color than the number
|
||||
of ROIs to allow for adding a new ROI without regenerating the colors.
|
||||
"""
|
||||
n = len(self._rois) + 1
|
||||
self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
|
||||
|
||||
def add_roi(self, roi: BaseROI):
|
||||
"""
|
||||
Registers an externally created ROI with this controller.
|
||||
|
||||
Adds the ROI to the internal list, assigns it a color from the color buffer,
|
||||
ensures it has an appropriate line width, and emits the roiAdded signal.
|
||||
|
||||
Args:
|
||||
roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
|
||||
such as RectangularROI or CircularROI.
|
||||
"""
|
||||
self._rois.append(roi)
|
||||
self._rebuild_color_buffer()
|
||||
idx = len(self._rois) - 1
|
||||
if roi.label == "ROI" or roi.label.startswith("ROI "):
|
||||
roi.label = f"ROI {idx}"
|
||||
color = self._colors[idx]
|
||||
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 = 5
|
||||
self.roiAdded.emit(roi)
|
||||
|
||||
def remove_roi(self, roi: BaseROI):
|
||||
"""
|
||||
Removes an ROI from this controller.
|
||||
|
||||
If the ROI is found in the internal list, it is removed, the color buffer
|
||||
is regenerated, and the roiRemoved signal is emitted.
|
||||
|
||||
Args:
|
||||
roi (BaseROI): The ROI instance to remove.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Returns the ROI at the specified index.
|
||||
|
||||
Args:
|
||||
index (int): The index of the ROI to retrieve.
|
||||
|
||||
Returns:
|
||||
BaseROI or None: The ROI at the specified index, or None if the index
|
||||
is out of range.
|
||||
"""
|
||||
if 0 <= index < len(self._rois):
|
||||
return self._rois[index]
|
||||
return None
|
||||
|
||||
def get_roi_by_name(self, name: str) -> BaseROI | None:
|
||||
"""
|
||||
Returns the first ROI with the specified name.
|
||||
|
||||
Args:
|
||||
name (str): The name to search for (case-sensitive).
|
||||
|
||||
Returns:
|
||||
BaseROI or None: The first ROI with a matching name, or None if no
|
||||
matching ROI is found.
|
||||
"""
|
||||
for r in self._rois:
|
||||
if r.label == name:
|
||||
return r
|
||||
return None
|
||||
|
||||
def remove_roi_by_index(self, index: int):
|
||||
"""
|
||||
Removes the ROI at the specified index.
|
||||
|
||||
Args:
|
||||
index (int): The index of the ROI to remove.
|
||||
"""
|
||||
roi = self.get_roi(index)
|
||||
if roi is not None:
|
||||
self.remove_roi(roi)
|
||||
|
||||
def remove_roi_by_name(self, name: str):
|
||||
"""
|
||||
Removes the first ROI with the specified name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the ROI to remove (case-sensitive).
|
||||
"""
|
||||
roi = self.get_roi_by_name(name)
|
||||
if roi is not None:
|
||||
self.remove_roi(roi)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Removes all ROIs from this controller.
|
||||
|
||||
Iterates through all ROIs and removes them one by one, then emits
|
||||
the cleared signal to notify listeners that all ROIs have been removed.
|
||||
"""
|
||||
for roi in list(self._rois):
|
||||
self.remove_roi(roi)
|
||||
self.cleared.emit()
|
||||
|
||||
def renormalize_colors(self):
|
||||
"""
|
||||
Reassigns palette colors to all ROIs in order.
|
||||
|
||||
Regenerates the color buffer based on the current colormap and number of ROIs,
|
||||
then assigns each ROI a color from the buffer in the order they were added.
|
||||
This is useful after changing the colormap or when ROIs need to be visually
|
||||
distinguished from each other.
|
||||
"""
|
||||
self._rebuild_color_buffer()
|
||||
for idx, roi in enumerate(self._rois):
|
||||
roi.line_color = self._colors[idx]
|
||||
|
||||
@SafeProperty(str)
|
||||
def colormap(self):
|
||||
"""
|
||||
Gets the name of the colormap used for ROI colors.
|
||||
|
||||
Returns:
|
||||
str: The name of the colormap.
|
||||
"""
|
||||
return self._colormap
|
||||
|
||||
@colormap.setter
|
||||
def colormap(self, cmap: str):
|
||||
"""
|
||||
Sets the colormap used for ROI colors.
|
||||
|
||||
Updates the internal colormap name and reassigns colors to all ROIs
|
||||
based on the new colormap.
|
||||
|
||||
Args:
|
||||
cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
|
||||
"""
|
||||
|
||||
self.set_colormap(cmap)
|
||||
|
||||
def set_colormap(self, cmap: str):
|
||||
Colors.validate_color_map(cmap)
|
||||
self._colormap = cmap
|
||||
self.paletteChanged.emit(cmap)
|
||||
self.renormalize_colors()
|
||||
|
||||
@property
|
||||
def rois(self) -> list[BaseROI]:
|
||||
"""
|
||||
Gets a copy of the list of ROIs managed by this controller.
|
||||
|
||||
Returns a new list containing all the ROIs currently managed by this controller.
|
||||
The list is a copy, so modifying it won't affect the controller's internal list.
|
||||
|
||||
Returns:
|
||||
list[BaseROI]: A list of all ROIs currently managed by this controller.
|
||||
"""
|
||||
return list(self._rois)
|
||||
|
||||
def cleanup(self):
|
||||
for roi in self._rois:
|
||||
self.remove_roi(roi)
|
||||
@@ -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)
|
||||
|
||||
@@ -19,13 +19,11 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
class HelloWorldWidget(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, client=None, gui_id: str | None = None
|
||||
self, parent: QWidget | None = None, client=None, gui_id: str | None = None, **kwargs
|
||||
) -> None:
|
||||
# Initialize the BECWidget and QWidget
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent)
|
||||
# Initialize base classes
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Create a label with the text "Hello World"
|
||||
self.label = QLabel(self)
|
||||
self.label.setText("Hello World")
|
||||
|
||||
|
||||
@@ -7,6 +7,4 @@ sphinx-copybutton
|
||||
sphinx-inline-tabs
|
||||
myst-parser
|
||||
sphinx-design
|
||||
PySide6~=6.8.2
|
||||
bec-widgets
|
||||
tomli
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.3.0"
|
||||
version = "2.6.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -71,7 +71,7 @@ include_trailing_comma = true
|
||||
known_first_party = ["bec_widgets"]
|
||||
|
||||
[tool.semantic_release]
|
||||
build_command = "python -m build"
|
||||
build_command = "pip install build wheel && python -m build"
|
||||
version_toml = ["pyproject.toml:project.version"]
|
||||
|
||||
[tool.semantic_release.commit_author]
|
||||
@@ -98,7 +98,7 @@ default_bump_level = 0
|
||||
[tool.semantic_release.remote]
|
||||
name = "origin"
|
||||
type = "github"
|
||||
ignore_token_for_push = false
|
||||
ignore_token_for_push = true
|
||||
|
||||
[tool.semantic_release.remote.token]
|
||||
env = "GH_TOKEN"
|
||||
|
||||
333
tests/unit_tests/test_image_roi_tree.py
Normal file
333
tests/unit_tests/test_image_roi_tree.py
Normal 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)
|
||||
207
tests/unit_tests/test_image_rois.py
Normal file
207
tests/unit_tests/test_image_rois.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture(params=["rect", "circle"])
|
||||
def bec_image_widget_with_roi(qtbot, request, mocked_client):
|
||||
"""Return (widget, roi, shape_label) for each ROI class."""
|
||||
|
||||
roi_type: Literal["rect", "circle"] = request.param
|
||||
|
||||
# Build an Image widget with a trivial 100×100 zeros array
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
data = np.zeros((100, 100), dtype=float)
|
||||
data[20:40, 20:40] = 5 # content assertion for roi to check
|
||||
widget.main_image.set_data(data)
|
||||
|
||||
# Add a single ROI via the public API
|
||||
roi = widget.add_roi(kind=roi_type)
|
||||
|
||||
yield widget, roi, roi_type
|
||||
|
||||
|
||||
def test_default_properties(bec_image_widget_with_roi):
|
||||
"""Label, width, type sanity‑check."""
|
||||
|
||||
_widget, roi, roi_type = bec_image_widget_with_roi
|
||||
|
||||
assert roi.label.startswith("ROI")
|
||||
|
||||
assert roi.line_width == 5
|
||||
|
||||
# concrete subclass type
|
||||
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
||||
|
||||
|
||||
def test_coordinate_structures(bec_image_widget_with_roi):
|
||||
"""Typed vs untyped coordinate structures are consistent."""
|
||||
|
||||
_widget, roi, _ = bec_image_widget_with_roi
|
||||
|
||||
raw = roi.get_coordinates(typed=False)
|
||||
typed = roi.get_coordinates(typed=True)
|
||||
|
||||
# untyped is always a tuple
|
||||
assert isinstance(raw, tuple)
|
||||
|
||||
# typed is always a dict and has same number of scalars as raw flattens to
|
||||
assert isinstance(typed, dict)
|
||||
assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
|
||||
np.ravel(raw)
|
||||
)
|
||||
|
||||
|
||||
def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
|
||||
"""Pixels reported by get_data_from_image have non‑zero size and match ROI extents."""
|
||||
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
|
||||
pixels = roi.get_data_from_image() # auto‑detect ImageItem
|
||||
|
||||
assert pixels.size > 0 # ROI covers at least one pixel
|
||||
|
||||
# For rectangular ROI: pixel bounding box equals coordinate bbox
|
||||
if isinstance(roi, RectangularROI):
|
||||
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
|
||||
# ensure ints inside image shape
|
||||
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
|
||||
expected = widget.main_image.image[y0:y1, x0:x1]
|
||||
assert pixels.shape == expected.shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0])
|
||||
def test_controller_remove_by_index(bec_image_widget_with_roi, index):
|
||||
"""Image.remove_roi(index) removes the graphics item and updates controller."""
|
||||
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
controller: ROIController = widget.roi_controller
|
||||
|
||||
assert controller.rois # non‑empty before
|
||||
|
||||
widget.remove_roi(index)
|
||||
|
||||
# ROI list now empty and item no longer in scene
|
||||
assert not controller.rois
|
||||
assert roi not in widget.plot_item.items
|
||||
|
||||
|
||||
def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
# add two of each ROI type
|
||||
for _kind in ("rect", "circle"):
|
||||
widget.add_roi(kind=_kind)
|
||||
widget.add_roi(kind=_kind)
|
||||
|
||||
colors = [r.line_color for r in widget.roi_controller.rois]
|
||||
assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
|
||||
|
||||
|
||||
def test_roi_label_and_signals(bec_image_widget_with_roi):
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
changed = []
|
||||
roi.nameChanged.connect(lambda name: changed.append(name))
|
||||
roi.label = "new_label"
|
||||
assert roi.label == "new_label"
|
||||
assert changed and changed[0] == "new_label"
|
||||
|
||||
|
||||
def test_roi_line_color_and_width(bec_image_widget_with_roi):
|
||||
_widget, roi, _ = bec_image_widget_with_roi
|
||||
changed = []
|
||||
roi.penChanged.connect(lambda: changed.append(True))
|
||||
roi.line_color = "#123456"
|
||||
assert roi.line_color == "#123456"
|
||||
roi.line_width = 5
|
||||
assert roi.line_width == 5
|
||||
assert changed # penChanged should have been emitted
|
||||
|
||||
|
||||
def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = widget.roi_controller
|
||||
r1 = widget.add_roi(kind="rect", name="r1")
|
||||
r2 = widget.add_roi(kind="circle", name="c1")
|
||||
assert r1 in controller.rois and r2 in controller.rois
|
||||
widget.remove_roi("r1")
|
||||
assert r1 not in controller.rois and r2 in controller.rois
|
||||
widget.remove_roi("c1")
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_controller_colormap_changes(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = widget.roi_controller
|
||||
widget.add_roi(kind="rect")
|
||||
widget.add_roi(kind="circle")
|
||||
old_colors = [r.line_color for r in controller.rois]
|
||||
controller.colormap = "plasma"
|
||||
new_colors = [r.line_color for r in controller.rois]
|
||||
assert old_colors != new_colors
|
||||
assert all(isinstance(c, str) for c in new_colors)
|
||||
|
||||
|
||||
def test_roi_controller_clear(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
widget.add_roi(kind="rect")
|
||||
widget.add_roi(kind="circle")
|
||||
controller = widget.roi_controller
|
||||
controller.clear()
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
roi = widget.add_roi(kind="rect")
|
||||
# Remove all images from scene
|
||||
for item in list(widget.plot_item.items):
|
||||
if hasattr(item, "image"):
|
||||
widget.plot_item.removeItem(item)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
roi.get_data_from_image()
|
||||
|
||||
|
||||
def test_roi_remove_cleans_up(bec_image_widget_with_roi):
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
roi.remove()
|
||||
assert roi not in widget.roi_controller.rois
|
||||
assert roi not in widget.plot_item.items
|
||||
|
||||
|
||||
def test_roi_controller_get_roi_methods(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
r1 = widget.add_roi(kind="rect", name="findme")
|
||||
r2 = widget.add_roi(kind="circle")
|
||||
controller = widget.roi_controller
|
||||
assert controller.get_roi_by_name("findme") == r1
|
||||
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
|
||||
@@ -329,3 +329,88 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
|
||||
assert bec_image_view.main_image.log is False
|
||||
assert bec_image_view.transpose is False
|
||||
assert bec_image_view.main_image.transpose is False
|
||||
|
||||
|
||||
def test_roi_add_remove_and_properties(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
# Add ROIs
|
||||
rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
|
||||
circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
|
||||
assert rect in view.roi_controller.rois
|
||||
assert circ in view.roi_controller.rois
|
||||
assert rect.label == "rect_roi"
|
||||
assert circ.label == "circ_roi"
|
||||
assert rect.line_width == 7
|
||||
assert circ.line_width == 5
|
||||
# Change properties
|
||||
rect.label = "rect_roi2"
|
||||
circ.line_color = "#ff0000"
|
||||
assert rect.label == "rect_roi2"
|
||||
assert circ.line_color == "#ff0000"
|
||||
# Remove by name
|
||||
view.remove_roi("rect_roi2")
|
||||
assert rect not in view.roi_controller.rois
|
||||
# Remove by index
|
||||
view.remove_roi(0)
|
||||
assert not view.roi_controller.rois
|
||||
|
||||
|
||||
def test_roi_controller_palette_signal(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = view.roi_controller
|
||||
changed = []
|
||||
controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
|
||||
view.add_roi(kind="rect")
|
||||
controller.colormap = "plasma"
|
||||
assert changed and changed[0] == "plasma"
|
||||
|
||||
|
||||
def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
r1 = view.add_roi(kind="rect", name="r1")
|
||||
r2 = view.add_roi(kind="circle", name="c1")
|
||||
controller = view.roi_controller
|
||||
assert controller.get_roi_by_name("r1") == r1
|
||||
assert controller.get_roi(1) == r2
|
||||
controller.clear()
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
roi = view.add_roi(kind="rect")
|
||||
# Remove all images from scene
|
||||
for item in list(view.plot_item.items):
|
||||
if hasattr(item, "image"):
|
||||
view.plot_item.removeItem(item)
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user