-
Notifications
You must be signed in to change notification settings - Fork 41
Autogenerate images of parts of the viewer #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TimMonko
wants to merge
19
commits into
napari:main
Choose a base branch
from
TimMonko:autogenerate-gui
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
2fb68c1
autogeneration-preliminary-work
TimMonko 63e8387
clean up
TimMonko 11a111a
barely working popups skeleton
TimMonko fe5f1b7
im lost this popup sometimes works but usually doesn't
TimMonko 8072f05
cleaner working popups
TimMonko d0e69d4
gui includes both viewer and popups -- but popups sometimes don't work
TimMonko 456cdb8
seems to work less but code is better
TimMonko 91cde7a
remove popups file -- outdated
TimMonko 31e81d0
clean up widget and menu popup capturing
TimMonko b2df727
got popups working consistently
TimMonko 70e7e03
add more popups, clean up code more
TimMonko 0995949
speed up slightly
TimMonko 3d8aec9
lengthen menu QTimer so as not to cause issue with popup generation
TimMonko 4326a74
Merge branch 'main' into autogenerate-gui
TimMonko 70b3373
Refactor autogenerate_images function and modularize widget, menu, an…
TimMonko 73c5ce1
add mouse over for status bar
TimMonko e267262
improve docstrings
TimMonko c20d526
Add functionality to capture viewer regions
TimMonko 49f456f
Merge branch 'main' into autogenerate-gui
TimMonko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,237 @@ | ||
from pathlib import Path | ||
|
||
from qtpy.QtCore import QTimer, QPoint | ||
from napari._qt.qt_event_loop import get_qapp | ||
from napari._qt.qt_resources import get_stylesheet | ||
from napari._qt.dialogs.qt_modal import QtPopup | ||
from qtpy.QtWidgets import QApplication | ||
import napari | ||
|
||
DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent | ||
IMAGES_PATH = DOCS / "images" / "_autogenerated" | ||
IMAGES_PATH.mkdir(parents=True, exist_ok=True) | ||
|
||
def autogenerate_images(): | ||
app = get_qapp() | ||
|
||
# Create viewer with visible window | ||
viewer = napari.Viewer(show=True) | ||
viewer.window._qt_window.resize(800, 600) | ||
viewer.window._qt_window.setStyleSheet(get_stylesheet("dark")) | ||
|
||
# Ensure window is active | ||
viewer.window._qt_window.activateWindow() | ||
viewer.window._qt_window.raise_() | ||
app.processEvents() | ||
|
||
viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False) | ||
# Add sample data | ||
viewer.open_sample(plugin='napari', sample='cells3d') | ||
|
||
app.processEvents() # Ensure viewer is fully initialized | ||
viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False) | ||
|
||
# Open the console | ||
viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") | ||
viewer_buttons.consoleButton.click() | ||
|
||
# Print Qt widget hierarchy | ||
print_widget_hierarchy(viewer.window._qt_window) | ||
|
||
# Wait for viewer to fully initialize and render | ||
QTimer.singleShot(2000, lambda: capture_elements(viewer)) | ||
|
||
app.exec_() | ||
|
||
def capture_elements(viewer): | ||
"""Capture specific UI elements based on the widget hierarchy.""" | ||
|
||
# Main components - using the hierarchy you provided | ||
components = { | ||
"welcome_widget": find_widget_by_class(viewer.window._qt_window, "QtWelcomeWidget"), | ||
|
||
"console_dock": find_widget_by_name(viewer.window._qt_window, "console"), # TODO: this was working? | ||
|
||
"dimension_slider": find_widget_by_class(viewer.window._qt_window, "QtDims"), #QtDimSliderWidget | ||
|
||
# Layer list components | ||
"layer_list_dock": find_widget_by_name(viewer.window._qt_window, "layer list"), | ||
"layer_buttons": find_widget_by_class(viewer.window._qt_window, "QtLayerButtons"), | ||
"layer_list": find_widget_by_class(viewer.window._qt_window, "QtLayerList"), | ||
"viewer_buttons": find_widget_by_class(viewer.window._qt_window, "QtViewerButtons"), | ||
|
||
# Layer controls | ||
"layer_controls_dock": find_widget_by_name(viewer.window._qt_window, "layer controls"), | ||
|
||
# TODO: mouse over part of the image to show intensity stuff | ||
"status_bar": viewer.window._status_bar, | ||
|
||
# Menus | ||
"menu_bar": find_widget_by_class(viewer.window._qt_window, "QMenuBar"), | ||
"file_menu": find_widget_by_name(viewer.window._qt_window, "napari/file"), | ||
"samples_menu": find_widget_by_name(viewer.window._qt_window, "napari/file/samples/napari"), | ||
"view_menu": find_widget_by_name(viewer.window._qt_window, "napari/view"), | ||
"layers_menu": find_widget_by_name(viewer.window._qt_window, "napari/layers"), | ||
"plugins_menu": find_widget_by_name(viewer.window._qt_window, "napari/plugins"), | ||
"window_menu": find_widget_by_name(viewer.window._qt_window, "napari/window"), | ||
"help_menu": find_widget_by_name(viewer.window._qt_window, "napari/help"), | ||
} | ||
|
||
# Capture each component | ||
for name, widget in components.items(): | ||
try: | ||
if widget is None: | ||
print(f"Could not find {name}") | ||
continue | ||
|
||
# For menus, need to show them first | ||
if name.endswith('_menu'): | ||
show_menu_for_screenshot(widget, name) | ||
continue | ||
|
||
pixmap = widget.grab() | ||
pixmap.save(str(IMAGES_PATH / f"{name}.png")) | ||
except Exception as e: | ||
print(f"Error capturing {name}: {e}") | ||
|
||
QTimer.singleShot(500, lambda: capture_viewer_button_popups(viewer)) | ||
|
||
# TODO: This needs to be done at the end of capture_elements and not autogenerate_images, why? | ||
# QTimer.singleShot(100, lambda: close_all(viewer)) | ||
|
||
def capture_viewer_button_popups(viewer): | ||
"""Capture popups that appear when clicking on viewer buttons.""" | ||
print("Capturing viewer button popups") | ||
viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons") | ||
|
||
# First capture ndisplay popup, when done it will trigger grid view | ||
capture_ndisplay_popup(viewer, viewer_buttons) | ||
|
||
def capture_ndisplay_popup(viewer, viewer_buttons): | ||
"""Capture the ndisplay button popup.""" | ||
|
||
# Switch to 3D mode to see all perspective controls | ||
viewer.dims.ndisplay = 3 | ||
close_existing_popups() | ||
|
||
# viewer_buttons.ndisplayButton.click() | ||
button = viewer_buttons.ndisplayButton | ||
button.customContextMenuRequested.emit(QPoint()) | ||
|
||
# Wait longer for the popup to appear | ||
QTimer.singleShot(500, lambda: find_and_capture_popup("ndisplay_popup", | ||
lambda: capture_grid_view_popup(viewer, viewer_buttons))) | ||
|
||
def capture_grid_view_popup(viewer, viewer_buttons): | ||
"""Capture the grid view button popup.""" | ||
close_existing_popups() | ||
|
||
button = viewer_buttons.gridViewButton | ||
button.customContextMenuRequested.emit(QPoint()) | ||
|
||
# Wait longer for the popup to appear and then close the app when done | ||
QTimer.singleShot(500, lambda: find_and_capture_popup("grid_view_popup", | ||
lambda: QTimer.singleShot(500, lambda: close_all(viewer)))) | ||
|
||
def find_and_capture_popup(name, next_function=None): | ||
"""Find any open QtPopup widgets and capture them.""" | ||
popup = None | ||
for widget in QApplication.topLevelWidgets(): | ||
if isinstance(widget, QtPopup): | ||
popup = widget | ||
break | ||
|
||
if popup: | ||
try: | ||
print(f"Found popup, capturing {name}...") | ||
get_qapp().processEvents() | ||
|
||
pixmap = popup.grab() | ||
pixmap.save(str(IMAGES_PATH / f"{name}.png")) | ||
popup.close() | ||
print(f"Captured and closed {name}") | ||
except Exception as e: | ||
print(f"Error grabbing popup {name}: {e}") | ||
else: | ||
print(f"No popup found for {name}") | ||
|
||
# Call the next function in sequence if provided | ||
if next_function: | ||
QTimer.singleShot(500, next_function) | ||
|
||
|
||
def show_menu_for_screenshot(menu, name): | ||
"""Show a menu and take screenshot of it.""" | ||
menu.popup(menu.parent().mapToGlobal(menu.pos())) | ||
|
||
# Give menu time to appear | ||
def grab_menu(): | ||
pixmap = menu.grab() | ||
pixmap.save(str(IMAGES_PATH / f"{name}.png")) | ||
menu.hide() | ||
|
||
QTimer.singleShot(300, grab_menu) | ||
|
||
def close_all(viewer): | ||
viewer.close() | ||
QTimer.singleShot(100, lambda: get_qapp().quit()) | ||
|
||
def close_existing_popups(): | ||
"""Close any existing popups.""" | ||
for widget in QApplication.topLevelWidgets(): | ||
if isinstance(widget, QtPopup): | ||
widget.close() | ||
|
||
get_qapp().processEvents() | ||
|
||
def find_widget_by_name(parent, name): | ||
"""Find a widget by its object name.""" | ||
if parent.objectName() == name: | ||
return parent | ||
|
||
for child in parent.children(): | ||
if hasattr(child, 'objectName') and child.objectName() == name: | ||
return child | ||
|
||
if hasattr(child, 'children'): | ||
found = find_widget_by_name(child, name) | ||
if found: | ||
return found | ||
|
||
return None | ||
|
||
def find_widget_by_class(parent, class_name): | ||
"""Find a child widget by its class name.""" | ||
if parent.__class__.__name__ == class_name: | ||
return parent | ||
|
||
for child in parent.children(): | ||
if child.__class__.__name__ == class_name: | ||
return child | ||
|
||
if hasattr(child, 'children'): | ||
found = find_widget_by_class(child, class_name) | ||
if found: | ||
return found | ||
|
||
return None | ||
|
||
|
||
def print_widget_hierarchy(widget, indent=0, max_depth=None): | ||
"""Print a hierarchy of child widgets with their class names and object names.""" | ||
|
||
if max_depth is not None and indent > max_depth: | ||
return | ||
|
||
class_name = widget.__class__.__name__ | ||
object_name = widget.objectName() | ||
name_str = f" (name: '{object_name}')" if object_name else "" | ||
print(" " * indent + f"- {class_name}{name_str}") | ||
|
||
for child in widget.children(): | ||
if hasattr(child, "children"): | ||
print_widget_hierarchy(child, indent + 4, max_depth) | ||
|
||
|
||
if __name__ == "__main__": | ||
autogenerate_images() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
""" | ||
Ultra-simple script to capture napari button popups using the same approach as napari's tests. | ||
""" | ||
from pathlib import Path | ||
from qtpy.QtCore import QPoint, QTimer | ||
from qtpy.QtWidgets import QApplication | ||
from napari._qt.qt_event_loop import get_qapp | ||
from napari._qt.dialogs.qt_modal import QtPopup | ||
import napari | ||
|
||
# Define paths | ||
IMAGES_PATH = Path(__file__).resolve().parent.parent / "images" / "_autogenerated" / "popups" | ||
IMAGES_PATH.mkdir(parents=True, exist_ok=True) | ||
|
||
def capture_popups(): | ||
"""Capture napari button popups.""" | ||
app = get_qapp() | ||
|
||
# Create viewer and add sample data | ||
viewer = napari.Viewer(show=True) | ||
viewer.window._qt_window.resize(800, 600) | ||
viewer.open_sample(plugin='napari', sample='cells3d') | ||
|
||
# Wait for viewer to initialize | ||
QTimer.singleShot(2000, lambda: start_capture(viewer)) | ||
app.exec_() | ||
|
||
def start_capture(viewer): | ||
"""Start the popup capture sequence.""" | ||
# Get the viewer buttons directly - like in the tests | ||
for widget in viewer.window._qt_window.findChildren(object): | ||
if widget.__class__.__name__ == "QtViewerButtons": | ||
viewer_buttons = widget | ||
break | ||
|
||
# First, set 3D mode for ndisplay popup | ||
viewer.dims.ndisplay = 3 | ||
get_qapp().processEvents() | ||
|
||
# Schedule the three popups in sequence with delays | ||
# First: ndisplay popup | ||
QTimer.singleShot(100, lambda: trigger_popup( | ||
viewer_buttons.ndisplayButton, | ||
"ndisplay_popup", | ||
lambda: trigger_popup( | ||
viewer_buttons.gridViewButton, | ||
"grid_popup", | ||
lambda: cleanup(viewer) | ||
) | ||
)) | ||
TimMonko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def trigger_popup(button, name, next_func): | ||
"""Trigger a popup by emitting a context menu request, then capture it.""" | ||
print(f"Triggering {name}...") | ||
|
||
# Trigger the popup - same as in the tests | ||
button.customContextMenuRequested.emit(QPoint()) | ||
|
||
# Give popup time to appear | ||
QTimer.singleShot(800, lambda: capture_popup(name, next_func)) | ||
|
||
def capture_popup(name, next_func): | ||
"""Capture the currently visible popup.""" | ||
# Find the popup | ||
popup = None | ||
for widget in QApplication.topLevelWidgets(): | ||
if isinstance(widget, QtPopup): | ||
popup = widget | ||
TimMonko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
break | ||
|
||
if popup: | ||
print(f"Found popup, capturing {name}...") | ||
get_qapp().processEvents() | ||
|
||
# Capture and save | ||
popup.grab().save(str(IMAGES_PATH / f"{name}.png")) | ||
popup.close() | ||
print(f"Saved {name}") | ||
else: | ||
print(f"No popup found for {name}") | ||
|
||
# Call the next function after a delay | ||
QTimer.singleShot(500, next_func) | ||
|
||
def cleanup(viewer): | ||
"""Close the viewer and quit.""" | ||
print("All captures complete") | ||
viewer.close() | ||
QTimer.singleShot(200, lambda: get_qapp().quit()) | ||
|
||
if __name__ == "__main__": | ||
capture_popups() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.