Skip to content

Commit 0580b52

Browse files
TimMonkomelissawm
andauthored
Autogenerate images of parts of the viewer (#621)
# References and Motivation With 0.6.0 on the way, there have been _many_ changes to the UI from 0.5.6, especially when considering the years of napari development. Much of these changes have been done by me, and in recognition of that, I would like to update the current static images used throughout the docs with dynamically generated ones, so that any future UI changes do not require thorough updates of the docs. This PR was originally conceived slightly before, and then [discussed at 2025-03-13/14 docs meeting](https://hackmd.io/10DKrIMhREmk9sa_QRirlg#2025-03-1314); the docs meeting concluded that this PR should be made quickly, merged, and then checked for any flakiness (since it requires a merge) or appearance issues in the auto-generation. Then, we can make specific updates to the script (or new scripts) in order to improve visualization for various places in the docs. These are presently designed as proof-of-principle and change things as needed. Conceivably, this can also replace images where we hide building up a napari viewer instance, only to take an nbscreenshot. I believe this script method is considerably faster, but does require a bit less intuitive of a setup compared to the screenshot method. Overall, this takes less than 3 seconds to generate over 20 images. I'm aware that many of these are not currently needed in the docs, but may provide inspiration and design principles, even for the autogeneration, moving forward. We'll clean up what exactly is autogenerated in follow-up PRs. In follow ups, we can also consider adding unique stylings to Qt elements on the fly, such as making a certain button of interest have a yellow border. Useful current places: 1. [viewer tutorial](https://napari.org/dev/tutorials/fundamentals/viewer.html#viewer-buttons) buttons, popups (e.g. we just added grid popup and the buttons below are already out of date),, anything with console, etc. quite a few static images in here 2. [layers tutorial](https://napari.org/dev/guides/layers.html) 3. [quick start](https://napari.org/dev/tutorials/fundamentals/quick_start.html). console related images 4. [various buttons and images in how to each layer](https://napari.org/dev/howtos/layers/image.html) And in the future, because we can more quickly generate images, we can conceivably use more focused images throughout the docs. This will require doing something with doc merges as force-orphan to prevent build up of repo size with changing binaries, though nicely, these images are so far quite small in size (comparatively) # Description In one single file, our goal is to autogenerate various elements of the UI; these are either initially gotten with `viewer.screenshot` of the current viewer state, or in follow-ups as specific pixmaps of the widgets (or a defined group of widgets). These pixmaps are defined by dictionaries at the top of the file, making it easier to find and contribute to an image of interest. It also serves as models for 'set up' of different element types (popups, menus, other states) These include: 1. single widget elements always visible in the UI 2. menus, and specific submenus 3. triggered popup widgets, like grid, roll, ndisplay 4. groups of widgets. these are my favorite, but will take the most thought to define because it allows us to dynamically 'crop' the viewer to areas of interest. Lovely! The current implementation prioritizes speed, but may need to add some QTimer / sleep events for CI, not sure. # Autogenerated image examples ## Viewer and folder structure ![image](https://github.com/user-attachments/assets/dc4b05f3-f485-4012-8d94-845f358f34c2) ## Single widgets ![image](https://github.com/user-attachments/assets/5a4fddad-410c-49f8-b3a1-e3e8ca23477c) ## Menus ![image](https://github.com/user-attachments/assets/c86426c9-6979-42ce-80ce-6cbdd3a03bf5) ## Popups ![image](https://github.com/user-attachments/assets/cc2611a9-95c2-4c8f-a35f-f81ad2285518) ## Combined groups of widgets ![image](https://github.com/user-attachments/assets/8a0ce0bf-5c37-407a-a6e4-571c19785c39) # Original PR Description This is _really_ a draft of trying to autogenerate images that we can use throughout the docs. I think this would _also_ speed up doc generation in certain areas with reusable doc images, so that nbscreenshot would not need to be as frequent. Everything is getting saved into `images/_autogenerated` and could be used elsewhere. 1. Tries to autogenerate grabbing and saving many parts of the viewer using the attributes of the viewer Needs: 1. Popups are not working right, sometimes they trigger, often its the dims slider popup. I probably need to just directly call the button, but I'm having a tough time figuring out where the buttons live. 6. I think the timing of things has me really confused. I think QTimer is necessary to wait for things to popup, but I can't figure out why these popups aren't working 7. Also sometimes the console popups and saves as a screenshot, other times its just the thin dock area prior to the console popup. Examples of what _should_ appear in autogenerated if CI does it the same was as locally ![image](https://github.com/user-attachments/assets/a9e3be59-bd61-481e-a046-62251177f5a8) --------- Co-authored-by: Melissa Weber Mendonça <[email protected]>
1 parent a441d4f commit 0580b52

File tree

1 file changed

+377
-0
lines changed

1 file changed

+377
-0
lines changed
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
from pathlib import Path
2+
3+
from qtpy.QtCore import QTimer, QPoint, QRect
4+
import napari
5+
6+
from napari._qt.qt_event_loop import get_qapp
7+
from napari._qt.qt_resources import get_stylesheet
8+
from napari._qt.dialogs.qt_modal import QtPopup
9+
from qtpy.QtWidgets import QApplication, QWidget
10+
11+
DOCS = REPO_ROOT_PATH = Path(__file__).resolve().parent.parent
12+
IMAGES_PATH = DOCS / "images" / "_autogenerated"
13+
IMAGES_PATH.mkdir(parents=True, exist_ok=True)
14+
WIDGETS_PATH = IMAGES_PATH / "widgets"
15+
WIDGETS_PATH.mkdir(parents=True, exist_ok=True)
16+
MENUS_PATH = IMAGES_PATH / "menus"
17+
MENUS_PATH.mkdir(parents=True, exist_ok=True)
18+
POPUPS_PATH = IMAGES_PATH / "popups"
19+
POPUPS_PATH.mkdir(parents=True, exist_ok=True)
20+
REGION_PATH = IMAGES_PATH / "regions"
21+
REGION_PATH.mkdir(parents=True, exist_ok=True)
22+
23+
def _get_widget_components(qt_window: QWidget) -> dict:
24+
"""Get visible widget components from the Qt window.
25+
26+
Parameters
27+
----------
28+
qt_window : QWidget
29+
qt_window of the viewer.
30+
31+
Returns
32+
-------
33+
dict
34+
Dictionary with keys corresponding to widget names for saving
35+
and values corresponding to the QWidget itself
36+
"""
37+
return {
38+
"welcome_widget": find_widget_by_class(qt_window, "QtWelcomeWidget"),
39+
40+
"console_dock": find_widget_by_name(qt_window, "console"),
41+
42+
"dimension_slider": find_widget_by_class(qt_window, "QtDims"),
43+
44+
# Layer list components
45+
"layer_list_dock": find_widget_by_name(qt_window, "layer list"),
46+
"layer_buttons": find_widget_by_class(qt_window, "QtLayerButtons"),
47+
"layer_list": find_widget_by_class(qt_window, "QtLayerList"),
48+
"viewer_buttons": find_widget_by_class(qt_window, "QtViewerButtons"),
49+
50+
# Layer controls
51+
"layer_controls_dock": find_widget_by_name(qt_window, "layer controls"),
52+
53+
# TODO: mouse over part of the image to show intensity stuff
54+
"status_bar": find_widget_by_class(qt_window, "ViewerStatusBar"),
55+
}
56+
57+
def _get_menu_components(qt_window: QWidget) -> dict:
58+
"""Get menu bar components from the Qt window.
59+
60+
Parameters
61+
----------
62+
qt_window : QWidget
63+
qt_window of the viewer.
64+
65+
Returns
66+
-------
67+
dict
68+
Dictionary with keys corresponding to menu names for saving
69+
and values corresponding to the menu widget location.
70+
"""
71+
72+
return {
73+
"file_menu": find_widget_by_name(qt_window, "napari/file"),
74+
"samples_menu": find_widget_by_name(qt_window, "napari/file/samples/napari"),
75+
"view_menu": find_widget_by_name(qt_window, "napari/view"),
76+
"layers_menu": find_widget_by_name(qt_window, "napari/layers"),
77+
"plugins_menu": find_widget_by_name(qt_window, "napari/plugins"),
78+
"window_menu": find_widget_by_name(qt_window, "napari/window"),
79+
"help_menu": find_widget_by_name(qt_window, "napari/help"),
80+
}
81+
82+
def _get_button_popups_configs(
83+
viewer: napari.Viewer,
84+
) -> list[dict]:
85+
"""Get configurations for capturing popups that appear when clicking on viewer buttons.
86+
87+
Parameters
88+
----------
89+
viewer : napari.Viewer
90+
91+
Returns
92+
-------
93+
list[dict]
94+
List of dictionaries with the following keys:
95+
- name: str
96+
Name of the popup.
97+
- prep: callable
98+
Function to prepare the viewer before opening the popup.
99+
- button: QtViewerButton
100+
Button that opens the popup.
101+
"""
102+
viewer_buttons = find_widget_by_class(
103+
viewer.window._qt_window,
104+
"QtViewerButtons"
105+
)
106+
return [
107+
{
108+
"name": "ndisplay_2D_popup",
109+
"prep": lambda: setattr(viewer.dims, "ndisplay", 2),
110+
"button": viewer_buttons.ndisplayButton,
111+
},
112+
{
113+
"name": "roll_dims_popup",
114+
"prep": lambda: setattr(viewer.dims, "ndisplay", 2),
115+
"button": viewer_buttons.rollDimsButton,
116+
},
117+
{
118+
"name": "ndisplay_3D_popup",
119+
"prep": lambda: setattr(viewer.dims, "ndisplay", 3),
120+
"button": viewer_buttons.ndisplayButton,
121+
},
122+
{
123+
"name": "grid_popup",
124+
"prep": None,
125+
"button": viewer_buttons.gridViewButton,
126+
}
127+
]
128+
129+
def _get_viewer_regions() -> list[dict]:
130+
"""Get regions of the viewer to capture as a single image.
131+
132+
Returns
133+
-------
134+
list[dict]
135+
List of dictionaries with the following keys:
136+
- name: str
137+
Name of the region.
138+
- components: list of str
139+
Names of components to determine the bounding region
140+
"""
141+
return [
142+
{
143+
"name": "console_and_buttons",
144+
"components": ["console_dock", "viewer_buttons"]
145+
},
146+
{
147+
"name": "layer_list_and_controls",
148+
"components": ["layer_list_dock", "layer_controls_dock"]
149+
},
150+
]
151+
152+
def autogenerate_images():
153+
"""Autogenerate images of the GUI components.
154+
155+
This function opens a napari viewer, takes screenshots of the GUI components,
156+
and saves them to the images/_autogenerated folder.
157+
158+
At first, the viewer is prepped for various states and screenshots are taken
159+
of the whole viewer, with a moused-over sample image.
160+
161+
Then, the function captures visible widgets, triggers menus, and then captures
162+
right-click button popups.
163+
164+
Finally, the viewer is closed and the Qt application is executed
165+
to ensure all widgets are properly cleaned up.
166+
"""
167+
app = get_qapp()
168+
169+
# Create viewer with visible window
170+
viewer = napari.Viewer(show=True)
171+
172+
# Print Qt widget hierarchy
173+
# print_widget_hierarchy(viewer.window._qt_window)
174+
175+
viewer.window._qt_window.resize(1000, 800)
176+
viewer.window._qt_window.setStyleSheet(get_stylesheet("dark"))
177+
178+
# Ensure window is active
179+
viewer.window._qt_window.activateWindow()
180+
viewer.window._qt_window.raise_()
181+
app.processEvents()
182+
183+
viewer.screenshot(str(IMAGES_PATH / "viewer_empty.png"), canvas_only=False)
184+
viewer.open_sample(plugin='napari', sample='cells3d')
185+
186+
# Mouse over canvas for status bar update
187+
viewer.layers.selection = [viewer.layers[0]]
188+
viewer.mouse_over_canvas = True
189+
viewer.cursor.position = [25, 50, 120]
190+
viewer.update_status_from_cursor()
191+
app.processEvents() # Ensure viewer is fully initialized
192+
193+
viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d.png"), canvas_only=False)
194+
195+
# Open the console
196+
viewer_buttons = find_widget_by_class(viewer.window._qt_window, "QtViewerButtons")
197+
viewer_buttons.consoleButton.click()
198+
app.processEvents()
199+
200+
viewer.screenshot(str(IMAGES_PATH / "viewer_cells3d_console.png"), canvas_only=False)
201+
202+
widget_componenets = _get_widget_components(viewer.window._qt_window)
203+
for name, widget in widget_componenets.items():
204+
capture_widget(widget, name)
205+
206+
menu_components = _get_menu_components(viewer.window._qt_window)
207+
for name, menu in menu_components.items():
208+
capture_menu(menu, name)
209+
210+
button_popups_configs = _get_button_popups_configs(viewer)
211+
for config in button_popups_configs:
212+
capture_popups(config)
213+
214+
for region in _get_viewer_regions():
215+
capture_viewer_region(viewer, region["components"], region["name"])
216+
217+
close_all(viewer)
218+
app.exec_()
219+
220+
def capture_popups(config):
221+
"""Capture popups that appear when clicking on viewer buttons."""
222+
app = get_qapp()
223+
close_existing_popups()
224+
225+
if config["prep"] is not None:
226+
config["prep"]()
227+
228+
app.processEvents()
229+
config["button"].customContextMenuRequested.emit(QPoint())
230+
app.processEvents()
231+
popups = [w for w in QApplication.topLevelWidgets() if isinstance(w, QtPopup) and w.isVisible()]
232+
233+
if not popups:
234+
return print(f"No popup found for {config['name']}")
235+
236+
popup = popups[-1] # grab the most recent popup, just in case
237+
238+
app.processEvents()
239+
240+
pixmap = popup.grab()
241+
pixmap.save(str(POPUPS_PATH / f"{config['name']}.png"))
242+
popup.close()
243+
app.processEvents()
244+
245+
def capture_widget(widget, name):
246+
"""Capture a widget and save it to a file."""
247+
if widget is None:
248+
return print(f"Could not find {name}")
249+
250+
pixmap = widget.grab()
251+
pixmap.save(str(WIDGETS_PATH / f"{name}.png"))
252+
return
253+
254+
def capture_menu(menu, name):
255+
"""Show a menu and take screenshot of it."""
256+
if menu is None:
257+
return print(f"Could not find menu {name}")
258+
259+
menu.popup(menu.parent().mapToGlobal(menu.pos()))
260+
261+
pixmap = menu.grab()
262+
pixmap.save(str(MENUS_PATH / f"{name}.png"))
263+
menu.hide()
264+
return
265+
266+
def capture_viewer_region(viewer, component_names, save_name):
267+
"""Capture a screenshot of a region containing multiple components.
268+
269+
Requires that the component is defined in _get_widget_components
270+
271+
Parameters
272+
----------
273+
viewer : napari.Viewer
274+
The napari viewer
275+
component_names : list of str
276+
Names of components to determine the bounding region
277+
save_name : str
278+
Name of the output image file
279+
"""
280+
app = get_qapp()
281+
qt_window = viewer.window._qt_window
282+
widget_components = _get_widget_components(qt_window)
283+
284+
# Find the bounding rectangle for all requested components
285+
min_x, min_y = float('inf'), float('inf')
286+
max_x, max_y = float('-inf'), float('-inf')
287+
288+
for name in component_names:
289+
if name not in widget_components or widget_components[name] is None:
290+
print(f"Component {name} not found, skipping")
291+
continue
292+
293+
widget = widget_components[name]
294+
# Map to global coordinates
295+
global_pos = widget.mapToGlobal(widget.rect().topLeft())
296+
global_rect = widget.rect()
297+
global_rect.moveTo(global_pos)
298+
299+
min_x = min(min_x, global_rect.left())
300+
min_y = min(min_y, global_rect.top())
301+
max_x = max(max_x, global_rect.right())
302+
max_y = max(max_y, global_rect.bottom())
303+
304+
if min_x == float('inf'):
305+
print(f"No valid components found for {save_name}")
306+
return
307+
308+
region = QRect(QPoint(min_x, min_y), QPoint(max_x, max_y))
309+
310+
app.processEvents()
311+
screen = QApplication.primaryScreen()
312+
pixmap = screen.grabWindow(0, region.x(), region.y(), region.width(), region.height())
313+
pixmap.save(str(REGION_PATH / f"{save_name}.png"))
314+
315+
def close_all(viewer):
316+
viewer.close()
317+
QTimer.singleShot(10, lambda: get_qapp().quit())
318+
319+
def close_existing_popups():
320+
"""Close any existing popups."""
321+
for widget in QApplication.topLevelWidgets():
322+
if isinstance(widget, QtPopup):
323+
widget.close()
324+
325+
get_qapp().processEvents()
326+
327+
def find_widget_by_name(parent, name):
328+
"""Find a widget by its object name."""
329+
if parent.objectName() == name:
330+
return parent
331+
332+
for child in parent.children():
333+
if hasattr(child, 'objectName') and child.objectName() == name:
334+
return child
335+
336+
if hasattr(child, 'children'):
337+
found = find_widget_by_name(child, name)
338+
if found:
339+
return found
340+
341+
return None
342+
343+
def find_widget_by_class(parent, class_name):
344+
"""Find a child widget by its class name."""
345+
if parent.__class__.__name__ == class_name:
346+
return parent
347+
348+
for child in parent.children():
349+
if child.__class__.__name__ == class_name:
350+
return child
351+
352+
if hasattr(child, 'children'):
353+
found = find_widget_by_class(child, class_name)
354+
if found:
355+
return found
356+
357+
return None
358+
359+
360+
def print_widget_hierarchy(widget, indent=0, max_depth=None):
361+
"""Print a hierarchy of child widgets with their class names and object names."""
362+
363+
if max_depth is not None and indent > max_depth:
364+
return
365+
366+
class_name = widget.__class__.__name__
367+
object_name = widget.objectName()
368+
name_str = f" (name: '{object_name}')" if object_name else ""
369+
print(" " * indent + f"- {class_name}{name_str}")
370+
371+
for child in widget.children():
372+
if hasattr(child, "children"):
373+
print_widget_hierarchy(child, indent + 4, max_depth)
374+
375+
376+
if __name__ == "__main__":
377+
autogenerate_images()

0 commit comments

Comments
 (0)