diff --git a/pyproject.toml b/pyproject.toml index bfd434e..ef30fc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py4D_browser" -version = "1.1.3" +version = "1.2.1" authors = [ { name="Steven Zeltmann", email="steven.zeltmann@lbl.gov" }, ] @@ -21,6 +21,7 @@ dependencies = [ "h5py", "numpy >= 1.19", "matplotlib >= 3.2.2", + "platformdirs", "PyQt5 >= 5.10", "pyqtgraph >= 0.11", "sigfig", diff --git a/src/py4D_browser/dialogs.py b/src/py4D_browser/dialogs.py index 58694a2..d86352e 100644 --- a/src/py4D_browser/dialogs.py +++ b/src/py4D_browser/dialogs.py @@ -1,6 +1,7 @@ -from py4DSTEM import DataCube, data, tqdmnd +from py4DSTEM import DataCube, data import pyqtgraph as pg import numpy as np +from tqdm import tqdm from PyQt5.QtWidgets import QFrame, QPushButton, QApplication, QLabel from PyQt5.QtCore import pyqtSignal from PyQt5.QtCore import Qt, QObject @@ -428,8 +429,9 @@ def reconstruct(self): qy_operator = qy_operator * -2.0j * np.pi # loop over images and shift - for mx, my in tqdmnd( - *mask.shape, + img_indices = np.argwhere(mask) + for mx, my in tqdm( + img_indices, desc="Shifting images", file=StatusBarWriter(self.parent.statusBar()), mininterval=1.0, diff --git a/src/py4D_browser/main_window.py b/src/py4D_browser/main_window.py index 60f9216..df13f24 100644 --- a/src/py4D_browser/main_window.py +++ b/src/py4D_browser/main_window.py @@ -11,6 +11,7 @@ QLabel, QToolTip, QPushButton, + QShortcut, ) from matplotlib.backend_bases import tools @@ -21,6 +22,7 @@ from pathlib import Path import importlib import os +import platformdirs from py4D_browser.utils import pg_point_roi, VLine, LatchingButton from py4D_browser.scalebar import ScaleBar @@ -97,6 +99,21 @@ def __init__(self, argv): self.datacube = None + # Load settings from cofig file + config_path = os.path.join( + platformdirs.user_config_dir("py4DGUI", "py4DSTEM"), "GUI_config.ini" + ) + print(f"Loading configuration from {config_path}") + QtCore.QCoreApplication.setOrganizationName("py4DSTEM") + QtCore.QCoreApplication.setOrganizationDomain("py4DSTEM.com") + QtCore.QCoreApplication.setApplicationName("py4DGUI") + self.settings = QtCore.QSettings(config_path, QtCore.QSettings.Format.IniFormat) + + # Reset stored state if so asked: + if os.environ.get("PY4DGUI_RESET"): + self.settings.remove("last_state") + print("Cleared saved state, using defaults...") + self.setup_menus() self.setup_views() @@ -108,7 +125,9 @@ def __init__(self, argv): font.setPointSize(10) QToolTip.setFont(font) - self.resize(1000, 800) + self.resize( + self.settings.value("last_state/window_size", QtCore.QSize(1000, 800)), + ) self.show() @@ -134,6 +153,7 @@ def setup_menus(self): self.load_auto_action = QAction("&Load Data...", self) self.load_auto_action.triggered.connect(self.load_data_auto) self.file_menu.addAction(self.load_auto_action) + self.load_auto_action.setShortcut(QtGui.QKeySequence("Ctrl+O")) self.load_mmap_action = QAction("Load &Memory Map...", self) self.load_mmap_action.triggered.connect(self.load_data_mmap) @@ -163,6 +183,8 @@ def setup_menus(self): for method in ["Raw float32", "py4DSTEM HDF5", "Plain HDF5"]: menu_item = datacube_export_menu.addAction(method) menu_item.triggered.connect(partial(self.export_datacube, method)) + if method == "py4DSTEM HDF5": + menu_item.setShortcut(QtGui.QKeySequence("Ctrl+S")) # Submenu to export virtual image vimg_export_menu = QMenu("Export Virtual Image", self) @@ -293,6 +315,9 @@ def setup_menus(self): diff_range_group = QActionGroup(self) diff_range_group.setExclusive(True) + scale_range_default = self.settings.value( + "last_state/diffraction_autorange", [0.1, 99.9], type=float + ) for scale_range in [(0, 100), (0.1, 99.9), (1, 99), (2, 98), (5, 95)]: action = QAction(f"{scale_range[0]}% – {scale_range[1]}%", self) diff_range_group.addAction(action) @@ -302,7 +327,10 @@ def setup_menus(self): partial(self.set_diffraction_autoscale_range, scale_range) ) # set default - if scale_range[0] == 2 and scale_range[1] == 98: + if ( + scale_range[0] == scale_range_default[0] + and scale_range[1] == scale_range_default[1] + ): action.setChecked(True) self.set_diffraction_autoscale_range(scale_range, redraw=False) @@ -315,6 +343,9 @@ def setup_menus(self): vimg_range_group = QActionGroup(self) vimg_range_group.setExclusive(True) + scale_range_default = self.settings.value( + "last_state/realspace_autorange", [0.1, 99.9], type=float + ) for scale_range in [(0, 100), (0.1, 99.9), (1, 99), (2, 98), (5, 95)]: action = QAction(f"{scale_range[0]}% – {scale_range[1]}%", self) vimg_range_group.addAction(action) @@ -324,7 +355,10 @@ def setup_menus(self): partial(self.set_real_space_autoscale_range, scale_range) ) # set default - if scale_range[0] == 2 and scale_range[1] == 98: + if ( + scale_range[0] == scale_range_default[0] + and scale_range[1] == scale_range_default[1] + ): action.setChecked(True) self.set_real_space_autoscale_range(scale_range, redraw=False) @@ -357,19 +391,23 @@ def setup_menus(self): detector_mode_group.addAction(detector_maximum_action) self.detector_menu.addAction(detector_maximum_action) - detector_CoM_magnitude = QAction("CoM Ma&gnitude", self) - detector_CoM_magnitude.setCheckable(True) - detector_CoM_magnitude.triggered.connect( - partial(self.update_real_space_view, True) - ) - detector_mode_group.addAction(detector_CoM_magnitude) - self.detector_menu.addAction(detector_CoM_magnitude) + detector_CoM = QAction("C&oM", self) + detector_CoM.setCheckable(True) + detector_CoM.triggered.connect(partial(self.update_real_space_view, True)) + detector_mode_group.addAction(detector_CoM) + self.detector_menu.addAction(detector_CoM) - detector_CoM_angle = QAction("CoM &Angle", self) - detector_CoM_angle.setCheckable(True) - detector_CoM_angle.triggered.connect(partial(self.update_real_space_view, True)) - detector_mode_group.addAction(detector_CoM_angle) - self.detector_menu.addAction(detector_CoM_angle) + detector_CoMx = QAction("CoM &X", self) + detector_CoMx.setCheckable(True) + detector_CoMx.triggered.connect(partial(self.update_real_space_view, True)) + detector_mode_group.addAction(detector_CoMx) + self.detector_menu.addAction(detector_CoMx) + + detector_CoMy = QAction("CoM &Y", self) + detector_CoMy.setCheckable(True) + detector_CoMy.triggered.connect(partial(self.update_real_space_view, True)) + detector_mode_group.addAction(detector_CoMy) + self.detector_menu.addAction(detector_CoMy) detector_iCoM = QAction("i&CoM", self) detector_iCoM.setCheckable(True) @@ -526,12 +564,7 @@ def setup_views(self): self.diffraction_space_widget.setMouseTracking(True) # Create virtual detector ROI selector - self.virtual_detector_point = pg_point_roi( - self.diffraction_space_widget.getView() - ) - self.virtual_detector_point.sigRegionChanged.connect( - partial(self.update_real_space_view, False) - ) + self.update_diffraction_detector() # Scalebar self.diffraction_scale_bar = ScaleBar(pixel_size=1, units="px", width=10) @@ -548,10 +581,7 @@ def setup_views(self): self.real_space_widget.setImage(np.zeros((512, 512))) # Add point selector connected to displayed diffraction pattern - self.real_space_point_selector = pg_point_roi(self.real_space_widget.getView()) - self.real_space_point_selector.sigRegionChanged.connect( - partial(self.update_diffraction_space_view, False) - ) + self.update_realspace_detector() # Scalebar, None by default self.real_space_scale_bar = ScaleBar(pixel_size=1, units="px", width=10) @@ -659,6 +689,10 @@ def setup_views(self): ) self.statusBar().addPermanentWidget(self.realspace_rescale_button) + def resizeEvent(self, event): + # Store window size for next run + self.settings.setValue("last_state/window_size", event.size()) + # Handle dragging and dropping a file on the window def dragEnterEvent(self, event): if event.mimeData().hasUrls(): diff --git a/src/py4D_browser/menu_actions.py b/src/py4D_browser/menu_actions.py index d7e4eaa..74a6ce4 100644 --- a/src/py4D_browser/menu_actions.py +++ b/src/py4D_browser/menu_actions.py @@ -196,7 +196,7 @@ def export_datacube(self, save_format: str): py4DSTEM.save(filename, self.datacube, mode="o") elif save_format == "Plain HDF5": - with h5py.File(filename, "o") as f: + with h5py.File(filename, "w") as f: f["array"] = self.datacube.data diff --git a/src/py4D_browser/update_views.py b/src/py4D_browser/update_views.py index 47ac0e1..0dc256a 100644 --- a/src/py4D_browser/update_views.py +++ b/src/py4D_browser/update_views.py @@ -7,7 +7,12 @@ from PyQt5.QtGui import QCursor import os -from py4D_browser.utils import pg_point_roi, make_detector, complex_to_Lab +from py4D_browser.utils import ( + pg_point_roi, + make_detector, + complex_to_Lab, + StatusBarWriter, +) def update_real_space_view(self, reset=False): @@ -23,14 +28,16 @@ def update_real_space_view(self, reset=False): assert detector_mode in [ "Integrating", "Maximum", - "CoM Magnitude", - "CoM Angle", + "CoM", + "CoM X", + "CoM Y", "iCoM", ], detector_mode # If a CoM method is checked, ensure linear scaling - if detector_mode in ["CoM Magnitude", "CoM Angle"] and scaling_mode != "Linear": - print("Warning! Setting linear scaling for CoM image") + scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") + if detector_mode == "CoM" and scaling_mode != "Linear": + self.statusBar().showMessage("Warning! Setting linear scaling for CoM image") self.vimg_scale_linear_action.setChecked(True) scaling_mode = "Linear" @@ -45,7 +52,8 @@ def update_real_space_view(self, reset=False): if detector_shape == "Rectangular": # Get slices corresponding to ROI slices, transforms = self.virtual_detector_roi.getArraySlice( - self.datacube.data[0, 0, :, :], self.diffraction_space_widget.getImageItem() + self.datacube.data[0, 0, :, :].T, + self.diffraction_space_widget.getImageItem(), ) slice_y, slice_x = slices @@ -119,7 +127,12 @@ def update_real_space_view(self, reset=False): return mask = mask.astype(np.float32) vimg = np.zeros((self.datacube.R_Nx, self.datacube.R_Ny)) - iterator = py4DSTEM.tqdmnd(self.datacube.R_Nx, self.datacube.R_Ny, disable=True) + iterator = py4DSTEM.tqdmnd( + self.datacube.R_Nx, + self.datacube.R_Ny, + file=StatusBarWriter(self.statusBar()), + mininterval=0.1, + ) if detector_mode == "Integrating": for rx, ry in iterator: @@ -144,10 +157,12 @@ def update_real_space_view(self, reset=False): CoMx -= np.mean(CoMx) CoMy -= np.mean(CoMy) - if detector_mode == "CoM Magnitude": - vimg = np.hypot(CoMx, CoMy) - elif detector_mode == "CoM Angle": - vimg = np.arctan2(CoMy, CoMx) + if detector_mode == "CoM": + vimg = CoMx + 1.0j * CoMy + elif detector_mode == "CoM X": + vimg = CoMx + elif detector_mode == "CoM Y": + vimg = CoMy elif detector_mode == "iCoM": dpc = py4DSTEM.process.phase.DPC(verbose=False) dpc.preprocess( @@ -174,17 +189,44 @@ def set_virtual_image(self, vimg, reset=False): def _render_virtual_image(self, reset=False): vimg = self.unscaled_realspace_image - scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") - assert scaling_mode in ["Linear", "Log", "Square Root"], scaling_mode + # for 2D images, use the scaling set by the user + # for RGB (3D) images, always scale linear + if np.isrealobj(vimg): + scaling_mode = self.vimg_scaling_group.checkedAction().text().replace("&", "") + assert scaling_mode in ["Linear", "Log", "Square Root"], scaling_mode + + if scaling_mode == "Linear": + new_view = vimg.copy() + elif scaling_mode == "Log": + new_view = np.log2(np.maximum(vimg, self.LOG_SCALE_MIN_VALUE)) + elif scaling_mode == "Square Root": + new_view = np.sqrt(np.maximum(vimg, 0)) + else: + raise ValueError("Mode not recognized") - if scaling_mode == "Linear": - new_view = vimg.copy() - elif scaling_mode == "Log": - new_view = np.log2(np.maximum(vimg, self.LOG_SCALE_MIN_VALUE)) - elif scaling_mode == "Square Root": - new_view = np.sqrt(np.maximum(vimg, 0)) + auto_level = reset or self.realspace_rescale_button.latched + + self.real_space_widget.setImage( + new_view.T, + autoLevels=False, + levels=( + ( + np.percentile(new_view, self.real_space_autoscale_percentiles[0]), + np.percentile(new_view, self.real_space_autoscale_percentiles[1]), + ) + if auto_level + else None + ), + autoRange=reset, + ) else: - raise ValueError("Mode not recognized") + new_view = complex_to_Lab(vimg) + self.real_space_widget.setImage( + np.transpose(new_view, (1, 0, 2)), # flip x/y but keep RGB ordering + autoLevels=False, + levels=(0, 1), + autoRange=reset, + ) stats_text = [ f"Min:\t{vimg.min():.5g}", @@ -197,27 +239,14 @@ def _render_virtual_image(self, reset=False): for t, m in zip(stats_text, self.realspace_statistics_actions): m.setText(t) - auto_level = reset or self.realspace_rescale_button.latched - - self.real_space_widget.setImage( - new_view.T, - autoLevels=False, - levels=( - ( - np.percentile(new_view, self.real_space_autoscale_percentiles[0]), - np.percentile(new_view, self.real_space_autoscale_percentiles[1]), - ) - if auto_level - else None - ), - autoRange=reset, - ) - # Update FFT view self.unscaled_fft_image = None - fft_window = np.hanning(vimg.shape[0])[:, None] * np.hanning(vimg.shape[1])[None, :] + vimg_2D = vimg if np.isrealobj(vimg) else np.abs(vimg) + fft_window = ( + np.hanning(vimg_2D.shape[0])[:, None] * np.hanning(vimg_2D.shape[1])[None, :] + ) if self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT": - fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg * fft_window))) ** 0.5 + fft = np.abs(np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window))) ** 0.5 levels = (np.min(fft), np.percentile(fft, 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.fft_widget_text.setText("Virtual Image FFT") @@ -233,7 +262,7 @@ def _render_virtual_image(self, reset=False): self.fft_source_action_group.checkedAction().text() == "Virtual Image FFT (complex)" ): - fft = np.fft.fftshift(np.fft.fft2(vimg * fft_window)) + fft = np.fft.fftshift(np.fft.fft2(vimg_2D * fft_window)) levels = (np.min(np.abs(fft)), np.percentile(np.abs(fft), 99.9)) mode_switch = self.fft_widget_text.textItem.toPlainText() != "Virtual Image FFT" self.fft_widget_text.setText("Virtual Image FFT") @@ -291,7 +320,7 @@ def update_diffraction_space_view(self, reset=False): elif detector_shape == "Rectangular": # Get slices corresponding to ROI slices, _ = self.real_space_rect_selector.getArraySlice( - np.zeros((self.datacube.Rshape)), self.real_space_widget.getImageItem() + np.zeros((self.datacube.Rshape)).T, self.real_space_widget.getImageItem() ) slice_y, slice_x = slices @@ -379,12 +408,18 @@ def update_realspace_detector(self): ) assert detector_shape in ["Point", "Rectangular"], detector_shape - if self.datacube is None: - return + main_pen = {"color": "g", "width": 6} + handle_pen = {"color": "r", "width": 9} + hover_pen = {"color": "c", "width": 6} + hover_handle = {"color": "c", "width": 9} - x, y = self.datacube.data.shape[:2] - x0, y0 = x / 2, y / 2 - xr, yr = x / 10, y / 10 + if self.datacube is None: + x0, y0 = 0, 0 + xr, yr = 4, 4 + else: + x, y = self.datacube.data.shape[2:] + y0, x0 = x // 2, y // 2 + xr, yr = (np.minimum(x, y) / 10,) * 2 # Remove existing detector if hasattr(self, "real_space_point_selector"): @@ -394,16 +429,27 @@ def update_realspace_detector(self): self.real_space_widget.view.scene().removeItem(self.real_space_rect_selector) self.real_space_rect_selector = None - # Rectangular detector + # Point detector if detector_shape == "Point": - self.real_space_point_selector = pg_point_roi(self.real_space_widget.getView()) + self.real_space_point_selector = pg_point_roi( + self.real_space_widget.getView(), + center=(x0 - 0.5, y0 - 0.5), + pen=main_pen, + hoverPen=hover_pen, + ) self.real_space_point_selector.sigRegionChanged.connect( partial(self.update_diffraction_space_view, False) ) + # Rectangular detector elif detector_shape == "Rectangular": self.real_space_rect_selector = pg.RectROI( - [int(x0 - xr / 2), int(y0 - yr / 2)], [int(xr), int(yr)], pen=(3, 9) + [int(x0 - xr / 2), int(y0 - yr / 2)], + [int(xr), int(yr)], + pen=main_pen, + handlePen=handle_pen, + hoverPen=hover_pen, + handleHoverPen=hover_handle, ) self.real_space_widget.getView().addItem(self.real_space_rect_selector) self.real_space_rect_selector.sigRegionChangeFinished.connect( @@ -422,12 +468,18 @@ def update_diffraction_detector(self): detector_shape = self.detector_shape_group.checkedAction().text().strip("&") assert detector_shape in ["Point", "Rectangular", "Circle", "Annulus"] - if self.datacube is None: - return + main_pen = {"color": "g", "width": 6} + handle_pen = {"color": "r", "width": 9} + hover_pen = {"color": "c", "width": 6} + hover_handle = {"color": "c", "width": 9} - x, y = self.datacube.data.shape[2:] - x0, y0 = x / 2, y / 2 - xr, yr = x / 10, y / 10 + if self.datacube is None: + x0, y0 = 0, 0 + xr, yr = 4, 4 + else: + x, y = self.datacube.data.shape[2:] + y0, x0 = x // 2, y // 2 + xr, yr = (np.minimum(x, y) / 10,) * 2 # Remove existing detector if hasattr(self, "virtual_detector_point"): @@ -449,18 +501,27 @@ def update_diffraction_detector(self): ) self.virtual_detector_roi_outer = None - # Rectangular detector + # Point detector if detector_shape == "Point": self.virtual_detector_point = pg_point_roi( - self.diffraction_space_widget.getView() + self.diffraction_space_widget.getView(), + center=(x0 - 0.5, y0 - 0.5), + pen=main_pen, + hoverPen=hover_pen, ) self.virtual_detector_point.sigRegionChanged.connect( partial(self.update_real_space_view, False) ) + # Rectangular detector elif detector_shape == "Rectangular": self.virtual_detector_roi = pg.RectROI( - [int(x0 - xr / 2), int(y0 - yr / 2)], [int(xr), int(yr)], pen=(3, 9) + [int(x0 - xr / 2), int(y0 - yr / 2)], + [int(xr), int(yr)], + pen=main_pen, + handlePen=handle_pen, + hoverPen=hover_pen, + handleHoverPen=hover_handle, ) self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi) self.virtual_detector_roi.sigRegionChangeFinished.connect( @@ -470,7 +531,12 @@ def update_diffraction_detector(self): # Circular detector elif detector_shape == "Circle": self.virtual_detector_roi = pg.CircleROI( - [int(x0 - xr / 2), int(y0 - yr / 2)], [int(xr), int(yr)], pen=(3, 9) + [int(x0 - xr / 2), int(y0 - yr / 2)], + [int(xr), int(yr)], + pen=main_pen, + handlePen=handle_pen, + hoverPen=hover_pen, + handleHoverPen=hover_handle, ) self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi) self.virtual_detector_roi.sigRegionChangeFinished.connect( @@ -481,7 +547,12 @@ def update_diffraction_detector(self): elif detector_shape == "Annulus": # Make outer detector self.virtual_detector_roi_outer = pg.CircleROI( - [int(x0 - xr), int(y0 - yr)], [int(2 * xr), int(2 * yr)], pen=(3, 9) + [int(x0 - xr), int(y0 - yr)], + [int(2 * xr), int(2 * yr)], + pen=main_pen, + handlePen=handle_pen, + hoverPen=hover_pen, + handleHoverPen=hover_handle, ) self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi_outer) @@ -489,7 +560,10 @@ def update_diffraction_detector(self): self.virtual_detector_roi_inner = pg.CircleROI( [int(x0 - xr / 2), int(y0 - yr / 2)], [int(xr), int(yr)], - pen=(4, 9), + pen=main_pen, + hoverPen=hover_pen, + handlePen=handle_pen, + handleHoverPen=hover_handle, movable=False, ) self.diffraction_space_widget.getView().addItem(self.virtual_detector_roi_inner) @@ -521,6 +595,7 @@ def update_diffraction_detector(self): def set_diffraction_autoscale_range(self, percentiles, redraw=True): self.diffraction_autoscale_percentiles = percentiles + self.settings.setValue("last_state/diffraction_autorange", list(percentiles)) if redraw: self._render_diffraction_image(reset=False) @@ -528,6 +603,7 @@ def set_diffraction_autoscale_range(self, percentiles, redraw=True): def set_real_space_autoscale_range(self, percentiles, redraw=True): self.real_space_autoscale_percentiles = percentiles + self.settings.setValue("last_state/realspace_autorange", list(percentiles)) if redraw: self._render_virtual_image(reset=False) @@ -594,9 +670,13 @@ def update_tooltip(self): if scene.getView().rect().contains(pos_in_scene): pos_in_data = scene.view.mapSceneToView(pos_in_scene) - y = int(np.clip(np.floor(pos_in_data.x()), 0, data.shape[0] - 1)) - x = int(np.clip(np.floor(pos_in_data.y()), 0, data.shape[1] - 1)) - display_text = f"[{x},{y}]: {data[x,y]:.5g}" + y = int(np.clip(np.floor(pos_in_data.x()), 0, data.shape[1] - 1)) + x = int(np.clip(np.floor(pos_in_data.y()), 0, data.shape[0] - 1)) + + if np.isrealobj(data): + display_text = f"[{x},{y}]: {data[x,y]:.5g}" + else: + display_text = f"[{x},{y}]: |z|={np.abs(data[x,y]):.5g}, ϕ={np.degrees(np.angle(data[x,y])):.5g}°" self.cursor_value_text.setText(display_text) diff --git a/src/py4D_browser/utils.py b/src/py4D_browser/utils.py index 03e1d21..b27ad7f 100644 --- a/src/py4D_browser/utils.py +++ b/src/py4D_browser/utils.py @@ -66,12 +66,12 @@ def on_click(self, *args): self.status_bar.showMessage("Shift+click to keep on", 5_000) -def pg_point_roi(view_box): +def pg_point_roi(view_box, center=(-0.5, -0.5), pen=(0, 9), hoverPen=None): """ Point selection. Based in pyqtgraph, and returns a pyqtgraph CircleROI object. This object has a sigRegionChanged.connect() signal method to connect to other functions. """ - circ_roi = pg.CircleROI((-0.5, -0.5), (2, 2), movable=True, pen=(0, 9)) + circ_roi = pg.CircleROI(center, (2, 2), movable=True, pen=pen, hoverPen=hoverPen) h = circ_roi.addTranslateHandle((0.5, 0.5)) h.pen = pg.mkPen("r") h.update()