From 2d6685a7f2a461339ab613e50009c0b62af4ff20 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 10:03:01 +0200 Subject: [PATCH 01/20] unnecessary depedency tables removed and instead uses h5py for reading the datasets --- PtyLab/io/readHdf5.py | 18 +++++++----------- pyproject.toml | 1 - 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/PtyLab/io/readHdf5.py b/PtyLab/io/readHdf5.py index fcfb12c7..a95b5056 100644 --- a/PtyLab/io/readHdf5.py +++ b/PtyLab/io/readHdf5.py @@ -3,7 +3,6 @@ import h5py import numpy as np -import tables from scipy.io import loadmat logger = logging.getLogger("readHdf5") @@ -48,17 +47,17 @@ def loadInputData(filename: Path, requiredFields, optionalFields): # start h5 loading, but check data fields first (defined above) dataset = dict() try: - with tables.open_file(str(filename), mode="r") as hdf5File: + with h5py.File(str(filename), "r") as hdf5File: # load the required fields for key in requiredFields: - value = hdf5File.root[key].read() + value = hdf5File[key][()] dataset[key] = scalify(value) # load optional fields, otherwise set to None and compute later for key in optionalFields: # check if the optional field exists otherwise set to None - if key in hdf5File.root: - value = hdf5File.root[key].read() + if key in hdf5File: + value = hdf5File[key][()] dataset[key] = scalify(value) else: dataset[key] = None @@ -110,12 +109,9 @@ def checkDataFields(filename, requiredFields): :return: None if correct :raise: KeyError if one of the attributes is missing. """ - with tables.open_file(str(filename), mode="r") as hdf5_file: - # get a list of nodes - nodes = hdf5_file.list_nodes("/") - # get the names of each node which will be the field names stored - # within the hdf5 file - fileFields = [node.name for node in nodes] + with h5py.File(str(filename), "r") as hdf5_file: + # the top-level member names are the field names stored in the file + fileFields = list(hdf5_file.keys()) # check if all the required fields are within the file for k in requiredFields: diff --git a/pyproject.toml b/pyproject.toml index 1aac6423..eb814b8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "scikit-learn~=1.3", "tqdm~=4.65", "pyqtgraph~=0.13", - "tables~=3.8", "bokeh~=3.2", "PyQt5~=5.15", "ipython~=8.18", From f1cbf2193a6861c8ba425d6a0a4f65e15543158a Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 10:07:25 +0200 Subject: [PATCH 02/20] minor key error message --- PtyLab/io/readHdf5.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PtyLab/io/readHdf5.py b/PtyLab/io/readHdf5.py index a95b5056..beb01f5d 100644 --- a/PtyLab/io/readHdf5.py +++ b/PtyLab/io/readHdf5.py @@ -50,6 +50,8 @@ def loadInputData(filename: Path, requiredFields, optionalFields): with h5py.File(str(filename), "r") as hdf5File: # load the required fields for key in requiredFields: + if key not in hdf5File: + raise KeyError("hdf5 file misses key %s" % key) value = hdf5File[key][()] dataset[key] = scalify(value) From e12261a1bd5b0a04d56cc82ff36c9f53342155d0 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 10:38:02 +0200 Subject: [PATCH 03/20] tensorflow is legacy and unused, so not part of dependencies, but file is kept with a message to install tensorflow separately if needed --- PtyLab/Monitor/TensorboardMonitor.py | 98 ++++++++++++++++------------ tests/Monitor/test_center_angle.py | 15 ----- 2 files changed, 57 insertions(+), 56 deletions(-) delete mode 100644 tests/Monitor/test_center_angle.py diff --git a/PtyLab/Monitor/TensorboardMonitor.py b/PtyLab/Monitor/TensorboardMonitor.py index a011effb..fb9dc316 100644 --- a/PtyLab/Monitor/TensorboardMonitor.py +++ b/PtyLab/Monitor/TensorboardMonitor.py @@ -1,20 +1,31 @@ +# NOTE: this module is optional and not tracked by the package metadata. It needs +# TensorFlow (for tensorboard summaries), which is not a PtyLab dependency or extra. +# To enable it, install TensorFlow separately. import io - -import numpy as np import time from pathlib import Path + +import matplotlib +import numpy as np from matplotlib.ticker import EngFormatter -from PtyLab.Monitor.Monitor import AbstractMonitor +from scipy import ndimage + +try: + from tensorflow import image + from tensorflow import summary as tfs +except ImportError as e: + raise ImportError( + "TensorboardMonitor needs TensorFlow, which is not a PtyLab dependency. " + "Install it separately." + ) from e + from PtyLab import Params +from PtyLab.Monitor.Monitor import AbstractMonitor from PtyLab.utils.gpuUtils import asNumpyArray from PtyLab.utils.utils import fft2c from PtyLab.utils.visualisation import complex2rgb, complex2rgb_vectorized -from tensorflow import summary as tfs -from scipy import ndimage -import matplotlib -import io -from tensorflow import image + def center_angle(object_estimate): # first, align the angle of the object based on the zeroth order mode object_estimate_0 = object_estimate.copy() @@ -38,7 +49,6 @@ def center_angle(object_estimate): class TensorboardMonitor(AbstractMonitor): - # maximum number of probe state mixtures that we want to show max_npsm = 10 # maximum number of object state mixtures we want to show @@ -96,7 +106,9 @@ def updatePlot( ) def visualize_probe_engine(self, engine): - RGB_image = complex2rgb_vectorized(engine.get_fundamental(), center_phase=self.center_phases) + RGB_image = complex2rgb_vectorized( + engine.get_fundamental(), center_phase=self.center_phases + ) self.__safe_upload_image("original probe", np.squeeze(RGB_image), self.i) pass @@ -172,36 +184,36 @@ def update_focusing_metric(self, TV_value, AOI_image, metric_name, allmerits=Non ) if allmerits is not None: import matplotlib.pyplot as plt + allmerits, new_z = allmerits - fig, ax = plt.subplot_mosaic('A') - ax = ax['A'] - ax.plot(allmerits[0], allmerits[1], '-ro') + fig, ax = plt.subplot_mosaic("A") + ax = ax["A"] + ax.plot(allmerits[0], allmerits[1], "-ro") ax.vlines(new_z, *ax.get_ylim()) - ax.set_title(f'{metric_name}') + ax.set_title(f"{metric_name}") buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=70) + fig.savefig(buf, format="png", dpi=70) plt.close(fig) buf.seek(0) with self.writer.as_default(): img = image.decode_png(buf.getvalue(), channels=4) img = np.expand_dims(img, 0) - tfs.image('Autofocus dz score', img, self.i) + tfs.image("Autofocus dz score", img, self.i) def updateBeamWidth(self, beamwidth_y, beamwidth_x): - self.__safe_upload_scalar('beamwidth/x_um', beamwidth_x*1e6, step=self.i) - self.__safe_upload_scalar('beamwidth/y_um', beamwidth_y*1e6, step=self.i) + self.__safe_upload_scalar("beamwidth/x_um", beamwidth_x * 1e6, step=self.i) + self.__safe_upload_scalar("beamwidth/y_um", beamwidth_y * 1e6, step=self.i) def update_overlap(self, overlap_area, linear_overlap): - self.__safe_upload_scalar('overlap/area', overlap_area, step=self.i) - self.__safe_upload_scalar('overlap/linear', linear_overlap, step=self.i) - + self.__safe_upload_scalar("overlap/area", overlap_area, step=self.i) + self.__safe_upload_scalar("overlap/linear", linear_overlap, step=self.i) def update_encoder( self, corrected_positions: np.ndarray, original_positions: np.ndarray, scaling: float = 1.0, - beamwidth=None + beamwidth=None, ) -> None: """ Update the stage position images. @@ -221,8 +233,6 @@ def update_encoder( axis=0, keepdims=True ) - - matplotlib.use("Agg") # no images output import matplotlib.pyplot as plt @@ -231,8 +241,9 @@ def update_encoder( if scaling > scale_0: scale_0 = scaling - position_range = np.min(original_positions.flatten()), np.max( - original_positions.flatten() + position_range = ( + np.min(original_positions.flatten()), + np.max(original_positions.flatten()), ) diff = np.diff(position_range) mean = np.mean(position_range) @@ -244,9 +255,7 @@ def update_encoder( figsize=(15, 8), ) - meandiff = np.mean( - abs(1e6 * corrected_positions - 1e6 * original_positions) - ) + meandiff = np.mean(abs(1e6 * corrected_positions - 1e6 * original_positions)) self.__safe_upload_scalar( "mean position displacement in micron", @@ -330,8 +339,9 @@ def _update_object_estimate( print("Angle shifts: ", shift1, shift2) # convert the object estimate to colour - object_estimate_rgb = complex2rgb_vectorized(object_estimate, center_phase=self.center_phases - ) + object_estimate_rgb = complex2rgb_vectorized( + object_estimate, center_phase=self.center_phases + ) # ensure it's 4 d as that's what is needed by tensorflow if object_estimate_rgb.ndim == 3: @@ -404,7 +414,7 @@ def _update_object_estimate( im = plt.imshow(I_object) plt.colorbar(im) if zo is not None: - plt.title(f"z = {zo*1e3:.3f} mm") + plt.title(f"z = {zo * 1e3:.3f} mm") plt.savefig(f"intensities/{self.i}.png") plt.savefig(f"intensities/AAA.png") @@ -412,7 +422,9 @@ def _update_probe_estimate(self, probe_estimate, highres=True): # first, convert it to images # while probe_estimate.ndim <= 3: # probe_estimate = probe_estimate[None] - probe_estimate_rgb = complex2rgb_vectorized(probe_estimate, center_phase=self.center_phases) + probe_estimate_rgb = complex2rgb_vectorized( + probe_estimate, center_phase=self.center_phases + ) # ensure it's 4 d as that's what is needed by tensorflow tag = "probe estimate" if not highres: @@ -426,19 +438,24 @@ def _update_probe_estimate(self, probe_estimate, highres=True): # make a probe COM estimate from scipy import ndimage + P = probe_estimate while P.ndim > 2: P = P[0] cy, cx = ndimage.center_of_mass(abs(P**2)) N = probe_estimate.shape[-1] - self.__safe_upload_scalar('com/cy', cy-N//2, self.i) - self.__safe_upload_scalar('com/cx', cx-N//2, self.i) + self.__safe_upload_scalar("com/cy", cy - N // 2, self.i) + self.__safe_upload_scalar("com/cx", cx - N // 2, self.i) return probe_estimate_rgb - - def __smart_upload_image_couldbecomplex( - self, name, data, step, max_outputs=3, description=None, center_phase=False, + self, + name, + data, + step, + max_outputs=3, + description=None, + center_phase=False, ): """ Safely upload an image that could be complex. If it is, cast it to colour before uploading. @@ -446,15 +463,14 @@ def __smart_upload_image_couldbecomplex( """ data = asNumpyArray(data) - if np.iscomplexobj(data): if center_phase: - phexp = data.sum((-2,-1), keepdims=True) + phexp = data.sum((-2, -1), keepdims=True) phexp = phexp.conj() / (abs(phexp) + 1e-9) else: phexp = 1 print("Got complex datatype") - data = complex2rgb_vectorized(data*phexp) + data = complex2rgb_vectorized(data * phexp) else: print("Got real datatype") # auto scale diff --git a/tests/Monitor/test_center_angle.py b/tests/Monitor/test_center_angle.py deleted file mode 100644 index c2b8c49f..00000000 --- a/tests/Monitor/test_center_angle.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest -import matplotlib -matplotlib.use("Agg") -import numpy as np -pytest.importorskip("tensorflow") -from PtyLab.Monitor.TensorboardMonitor import center_angle -from scipy import ndimage - - -def test_center_angle(): - N = 128 - Ein = np.fft.fftshift(ndimage.fourier_shift(np.ones((N, N)), [5, 0])) - Ein_c = center_angle(Ein) - assert Ein_c is not None - assert Ein_c.shape == Ein.shape From e7434c8c9f015daaa197be905659dd0ec99c6a16 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 10:41:26 +0200 Subject: [PATCH 04/20] also removing a tensorflow dependent test file as we don't need this --- tests/Monitor/test_TensorboardMonitor.py | 53 ------------------------ 1 file changed, 53 deletions(-) delete mode 100644 tests/Monitor/test_TensorboardMonitor.py diff --git a/tests/Monitor/test_TensorboardMonitor.py b/tests/Monitor/test_TensorboardMonitor.py deleted file mode 100644 index 21253975..00000000 --- a/tests/Monitor/test_TensorboardMonitor.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -import numpy as np -pytest.importorskip("tensorflow") -from PtyLab.Monitor.TensorboardMonitor import TensorboardMonitor - -imageio = pytest.importorskip("imageio") - - -@pytest.fixture -def monitor(): - return TensorboardMonitor(name="testing purposes only") - - -def test_diffraction_data(monitor): - estimated = imageio.imread("imageio:camera.png") - measured = np.fliplr(estimated) - for i in range(10): - monitor.i = i - monitor.updateDiffractionDataMonitor(estimated, measured) - - -def test_update_object_probe(monitor): - object_estimate = np.random.rand(1, 1280, 1280) - probe_estimate = np.random.rand(1, 64, 64) * 1j - for i in range(10): - monitor.updatePlot(object_estimate, probe_estimate) - - -def test_update_error(monitor): - errors = np.random.rand(100).cumsum()[::-1] - for e in errors: - monitor.i += 1 - monitor.updateObjectProbeErrorMonitor(e) - monitor.updateObjectProbeErrorMonitor([e]) - - -def test_update_z(monitor): - z = np.random.rand(100) / 100 - z[0] = 10 - z[30:50] = 0 - z[80:120] = 0 - z = z.cumsum() - for zi in z: - monitor.i += 1 - monitor.update_z(zi) - - -def test_update_positions(monitor): - positions = np.random.rand(100, 2).cumsum(axis=1) - positions = np.cos(positions) - scaling = 1.5 - other_positions = positions + np.random.rand(*positions.shape) * 1e-2 - monitor.update_positions(positions, scaling * other_positions, scaling) From 6e7fc5263422a7531d6d8e6d19f24247f1a2ec48 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 11:45:49 +0200 Subject: [PATCH 05/20] relies on pyqtgraph/Qt only optionally and controlled with the gui extras under dependency --- PtyLab/utils/visualisation.py | 129 ++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/PtyLab/utils/visualisation.py b/PtyLab/utils/visualisation.py index d33f9355..7c94a2e2 100644 --- a/PtyLab/utils/visualisation.py +++ b/PtyLab/utils/visualisation.py @@ -1,5 +1,10 @@ # This file contains utilities required for Monitor +import functools +import logging import math +import os +import subprocess +import sys import matplotlib as mpl import matplotlib.pyplot as plt @@ -9,10 +14,7 @@ from PtyLab.utils.gpuUtils import asNumpyArray, getArrayModule, isGpuArray -try: - import pyqtgraph as pg -except ModuleNotFoundError: - print("Pyqtgraph not available") +logger = logging.getLogger(__name__) def hsv2rgb(hsv: np.ndarray) -> np.ndarray: @@ -289,17 +291,89 @@ def _is_notebook(): """Return True when running inside a Jupyter kernel.""" try: from IPython import get_ipython + return get_ipython().__class__.__name__ == "ZMQInteractiveShell" except NameError: return False +def _has_display(): + """Return True when a Qt window can be shown. + + On Linux (e.g. a GPU server reached over ssh) Qt needs an X11 or Wayland + display; without one it aborts the whole process from C++, which Python + cannot catch, so this has to be checked before Qt starts. Setting + `QT_QPA_PLATFORM` explicitly (e.g. `offscreen`) is respected as an override. + """ + if not sys.platform.startswith("linux"): + return True + return any( + os.environ.get(v) for v in ("DISPLAY", "WAYLAND_DISPLAY", "QT_QPA_PLATFORM") + ) + + +@functools.lru_cache(maxsize=1) +def _qt_start_error(): + """Return None if a QApplication can start here, else Qt's error message. + + A display being set is not enough on Linux: e.g. over `ssh -X` Qt >= 6.5 also + needs the system library libxcb-cursor0, and when anything like that is + missing Qt aborts the process from C++ (uncatchable from Python). So start a + throwaway QApplication in a subprocess first. Cached: checked once per session. + """ + if not sys.platform.startswith("linux"): + return None + code = "from pyqtgraph.Qt import QtWidgets; QtWidgets.QApplication([])" + try: + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as e: + return str(e) + if result.returncode == 0: + return None + # Qt's first stderr line names the cause, e.g. "... libxcb-cursor0 is needed ..." + lines = [l for l in result.stderr.splitlines() if l.strip()] + return lines[0] if lines else f"exit code {result.returncode}" + + +def _show3Dslider_matplotlib(A, cmap): + """Fallback 3D viewer using matplotlib's own Slider widget (no Qt needed).""" + from matplotlib.widgets import Slider + + fig, ax = plt.subplots(figsize=(6, 6.5)) + # leave room under the image for the slider axis + fig.subplots_adjust(bottom=0.15) + im = ax.imshow(A[0], cmap=cmap, origin="lower") + fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + ax.axis("off") + + slider_ax = fig.add_axes((0.2, 0.05, 0.6, 0.03)) + slider = Slider(slider_ax, "Frame", 0, A.shape[0] - 1, valinit=0, valstep=1) + + def update_frame(value): + im.set_data(A[int(value)]) + fig.canvas.draw_idle() + + slider.on_changed(update_frame) + # the canvas holds this lambda strongly, which keeps the slider alive while the + # figure is open (with a non-blocking plt.show it would otherwise be collected) + fig.canvas.mpl_connect("close_event", lambda _: slider.disconnect_events()) + plt.show() + + def show3Dslider(A, colormap="diffraction"): """ show a 3D plot with a slider. In a Jupyter notebook an inline ipywidgets slider is used. - In a script the interactive pyqtgraph viewer is used. + In a script the interactive pyqtgraph viewer is used. If pyqtgraph is not + importable or there is no display (e.g. ssh without X11 forwarding), it + falls back to a matplotlib slider. :param A: a 3D array :param colormap: matplotlib colormap, default, customized colormap for plotting diffraction data @@ -311,12 +385,12 @@ def show3Dslider(A, colormap="diffraction"): if colormap == "diffraction": cmap = setColorMap() else: - cmap = mpl.cm.get_cmap(colormap) + cmap = mpl.colormaps[colormap] if _is_notebook(): import ipywidgets as widgets - from IPython.display import display import matplotlib.pyplot as plt + from IPython.display import display # Create output widget for displaying figure out = widgets.Output() @@ -325,7 +399,7 @@ def update_frame(change): # Create new figure for each frame update # Handle both old API (event dict) and new API (direct value) if isinstance(change, dict): - frame = int(change['new']) + frame = int(change["new"]) else: frame = int(change) fig, ax = plt.subplots(1, 1, figsize=(6, 6)) @@ -341,11 +415,7 @@ def update_frame(change): # Create slider widget slider = widgets.IntSlider( - min=0, - max=A.shape[0] - 1, - step=1, - value=0, - description="Frame" + min=0, max=A.shape[0] - 1, step=1, value=0, description="Frame" ) # Link slider to update function @@ -357,6 +427,36 @@ def update_frame(change): # Initial display update_frame(0) else: + if not _has_display(): + logger.warning( + "No display found (DISPLAY/WAYLAND_DISPLAY unset); using the " + "matplotlib viewer instead of pyqtgraph." + ) + _show3Dslider_matplotlib(A, cmap) + return + try: + # optional `gui` extra; pulls in the Qt binding (PySide6 by default, + # any binding pyqtgraph supports works) + import pyqtgraph as pg + except ImportError as e: + logger.warning( + "pyqtgraph/Qt not available (%s); using the matplotlib viewer. For the " + 'interactive GUI, install PtyLab with `pip install "ptylab[gui]"`.', + e, + ) + _show3Dslider_matplotlib(A, cmap) + return + # an existing QApplication proves Qt works; otherwise probe before starting one + if pg.Qt.QtWidgets.QApplication.instance() is None: + error = _qt_start_error() + if error is not None: + logger.warning( + "Qt cannot start (%s); using the matplotlib viewer. On Linux, " + "`sudo apt install libxcb-cursor0` usually fixes this.", + error, + ) + _show3Dslider_matplotlib(A, cmap) + return app = pg.mkQApp() imv = pg.ImageView(view=pg.PlotItem()) imv.setWindowTitle("Close to proceed") @@ -367,4 +467,5 @@ def update_frame(change): colors = [(np.array(cmap(i)[:-1]) * 255).astype("int") for i in positions] imv.setColorMap(pg.ColorMap(pos=positions, color=colors)) imv.show() - app.exec_() + # exec() works on every Qt binding; exec_() is a deprecated PyQt5-era alias + app.exec() From 16332f9b4fddeec9f944c2f95e6db5aa2d710c06 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 11:53:18 +0200 Subject: [PATCH 06/20] cleanups to remove unused imports/no reliance on pyqtgraph, etc --- PtyLab/ExperimentalData/ExperimentalData.py | 61 ++++++++++----------- PtyLab/utils/utils.py | 2 - 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/PtyLab/ExperimentalData/ExperimentalData.py b/PtyLab/ExperimentalData/ExperimentalData.py index 38c10b3a..02a88c86 100644 --- a/PtyLab/ExperimentalData/ExperimentalData.py +++ b/PtyLab/ExperimentalData/ExperimentalData.py @@ -1,22 +1,20 @@ -import numpy as np -from PtyLab.utils.gpuUtils import (transfer_fields_to_cpu, - transfer_fields_to_gpu) - -try: - import pyqtgraph as pg -except ImportError: - print("Cannot use pyqtgraph") -# from pathlib import Path import logging import matplotlib.pyplot as plt -# import tables +import numpy as np + from PtyLab.io import readHdf5 -from PtyLab.utils.gpuUtils import (getArrayModule, transfer_fields_to_cpu, - transfer_fields_to_gpu) +from PtyLab.utils.gpuUtils import ( + getArrayModule, + transfer_fields_to_cpu, + transfer_fields_to_gpu, +) + # from PtyLab.io import readExample from PtyLab.utils.visualisation import setColorMap, show3Dslider +# from pathlib import Path + class ExperimentalData: """ @@ -81,10 +79,10 @@ class ExperimentalData: magnification (float): Microscope magnification. Available for FPM datasets, Used to convert the detector pixel size to the sample-plane pixel size. - + NA (float or None): Microscope numerical aperture optional for FPM. If not provided, it is estimated from the Fourier-space pupil diameter during reconstruction. - + energyAtPos (np.ndarray): Integrated diffraction intensity for each measurement frame, obtained by summing the ptychogram over the detector pixels. Used to normalize the reconstruction error for each scan position. @@ -107,9 +105,7 @@ def __init__(self, filename=None, operationMode="CPM"): self.logger = logging.getLogger("ExperimentalData") self.logger.debug("Initializing ExperimentalData object") - self.operationMode = ( - operationMode # Select the data schema for CPM or FPM. - ) + self.operationMode = operationMode # Select the data schema for CPM or FPM. self._setFields() if filename is not None: self.loadData(filename) @@ -222,7 +218,7 @@ def loadData(self, filename=None): ) # Expose the loaded dataset fields as ExperimentalData attributes. attributesToSet = measurementDict.keys() - + # self.logger.setLevel(logging.DEBUG) for a in attributesToSet: # make sure that property is not an attribtue @@ -251,15 +247,15 @@ def reduce_positions(self, start, end): Index at which to stop the selection. The measurement at this index is not included. - Notes: + Notes: This method modifies ``self.ptychogram`` and ``self.encoder`` in place. """ - self.ptychogram = self.ptychogram[start: end] - self.encoder = self.encoder[start: end] + self.ptychogram = self.ptychogram[start:end] + self.encoder = self.encoder[start:end] self._setData() def cropCenter(self, size): - ''' + """ Crop each diffraction pattern to a centered square region. The ptychogram is cropped along its two detector dimensions while the @@ -279,20 +275,22 @@ def cropCenter(self, size): Derived detector quantities such as ``Nd`` and ``Ld`` are currently not recomputed by this method. - ''' + """ if not isinstance(size, int): - raise TypeError('Crop value is not valid. Int expected') + raise TypeError("Crop value is not valid. Int expected") x = self.ptychogram.shape[-1] startx = x // 2 - (size // 2) startx += 1 - self.ptychogram = self.ptychogram[..., startx: startx + size, startx: startx + size] + self.ptychogram = self.ptychogram[ + ..., startx : startx + size, startx : startx + size + ] self._setData() def binData(self, binning): - ''' + """ Spatially bin each diffraction pattern by averaging neighboring pixels. Each ``binning × binning`` detector region is replaced by its mean value, @@ -308,7 +306,7 @@ def binData(self, binning): The current implementation performs mean binning, so each output pixel contains the average intensity of the corresponding input pixel block. - ''' + """ Ndp = self.ptychogram.shape[0] Ny = self.ptychogram.shape[1] Nx = self.ptychogram.shape[2] @@ -452,7 +450,6 @@ def _move_data_to_gpu(self): """Move all required fata to the GPU""" transfer_fields_to_gpu(self, self.fields_to_transfer, self.logger) - def relative_intensity(self, index): """ Return the normalized mean intensity of a selected ptychogram frame. @@ -475,7 +472,9 @@ def relative_intensity(self, index): The normalized intensities are cached after the first call. """ - if not hasattr(self, '_relative_intensity'): - self._relative_intensity = self.ptychogram.mean((-2,-1)) - self._relative_intensity /= (self._relative_intensity.mean() + 2*self._relative_intensity.std()) + if not hasattr(self, "_relative_intensity"): + self._relative_intensity = self.ptychogram.mean((-2, -1)) + self._relative_intensity /= ( + self._relative_intensity.mean() + 2 * self._relative_intensity.std() + ) return self._relative_intensity[index] diff --git a/PtyLab/utils/utils.py b/PtyLab/utils/utils.py index 4efb4d86..bfbad5c9 100644 --- a/PtyLab/utils/utils.py +++ b/PtyLab/utils/utils.py @@ -1,8 +1,6 @@ import logging import numpy as np -from scipy import linalg -import scipy.stats as st from PtyLab.utils.gpuUtils import asNumpyArray, getArrayModule From b5ea8fe4a117a43ccd0b454011e63b8399a53be3 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 11:54:49 +0200 Subject: [PATCH 07/20] no longer relies on Bokeh for zPIE and make_alignment_plot uses matplotlib --- PtyLab/Engines/zPIE.py | 19 --- PtyLab/Reconstruction/Reconstruction.py | 156 +++++++++++------------- 2 files changed, 69 insertions(+), 106 deletions(-) diff --git a/PtyLab/Engines/zPIE.py b/PtyLab/Engines/zPIE.py index 1d03c685..9c9a1a99 100644 --- a/PtyLab/Engines/zPIE.py +++ b/PtyLab/Engines/zPIE.py @@ -343,22 +343,3 @@ def probeUpdate(self, objectPatch: np.ndarray, DELTA: np.ndarray): frac * DELTA, axis=(0, 1, 3), keepdims=True ) return r - - # def display_focus_bokeh(self): - # # first, make a document - # from bokeh.plotting import figure, output_file, save - # from bokeh.io import hplot - # from pathlib import Path - # - # folder = Path('plots/zPIE.html') - # output_file(folder) - # s1, s2 = self.reconstruction.make_alignment_plot(False) - # s3 = figure(width=250, height=250, title='TV per focus') - # - # - # s3.xaxix.axis_label = 'Distance [um]' - # s3.yaxix.axis_label = 'TV' - # s3.line(self.dz*1e6, self.merit, ) - # pass - # - # diff --git a/PtyLab/Reconstruction/Reconstruction.py b/PtyLab/Reconstruction/Reconstruction.py index f4a384ca..9fc8f271 100644 --- a/PtyLab/Reconstruction/Reconstruction.py +++ b/PtyLab/Reconstruction/Reconstruction.py @@ -451,107 +451,89 @@ def make_alignment_plot(self, saveit=False): Args: saveit (bool, optional): If True, save the diagnostic plots to - ``plots/alignment.html``. Defaults to False. + ``plots/alignment.png``. Defaults to False. Returns: - bokeh.layouts.LayoutDOM: - Bokeh layout containing the available diagnostic plots. + matplotlib.figure.Figure: + Figure with one panel per available diagnostic. In a notebook, + leaving the call as the last line of a cell displays it. Notes: Position coordinates are derived from ``positions`` and ``positions0``. These quantities are expressed in reconstruction pixels. """ - t0 = time.time() - p_new = self.positions.T - p_old = self.positions0.T - - from bokeh.plotting import figure, output_file, save - from bokeh.layouts import row - - from pathlib import Path + # a bare Figure (not pyplot) never opens a window or registers with the GUI + # backend, so zPIE can call this every iteration headless without piling up figures + from matplotlib.figure import Figure - if saveit: - output = Path("plots/alignment.html") - output.parent.mkdir(exist_ok=True) - # set output to static HTML file - - output_file(filename=output, title="Static HTML file", mode="inline") - - # create a new plot with a specific size - p = figure( - sizing_mode="stretch_width", - max_width=500, - height=500, - title=f'alignment (updated {time.strftime("%Y%h%d, %H:%M:%S")})', - ) - p.match_aspect = True - p.square( - p_old[0], p_old[1], fill_color="yellow", size=5, legend_label="original" - ) - # add a circle renderer for the new points - p.circle( - p_new[0], p_new[1], fill_color="red", size=5, legend_label="new" + t0 = time.time() + # positions are (row, col) in pixels; plot column on x and row on y + p_new = np.asarray(self.positions) + p_old = np.asarray(self.positions0) + + # collect the panels that have data, then lay them out in one row + panels = ["alignment"] + if hasattr(self, "zHistory"): + panels.append("focus") + if hasattr(self, "TV_history") and len(self.TV_history) >= 1: + panels.append("tv") + if hasattr(self, "merit"): + panels.append("merit") + + fig = Figure(figsize=(4.5 * len(panels), 4.5), layout="constrained") + axes = dict(zip(panels, fig.subplots(1, len(panels), squeeze=False)[0])) + + ax = axes["alignment"] + ax.scatter( + p_old[:, 1], p_old[:, 0], marker="s", s=12, c="gold", + edgecolors="k", linewidths=0.3, label="original", ) - - p.xaxis.axis_label = "Position x [um]" - p.yaxis.axis_label = "Position y [um]" - - p2 = None - p3 = None - p4 = None - - figsize = 500 # px - - if hasattr(self, "zHistory"): # display the plot of the defocus - p2 = figure( - sizing_mode="stretch_width", - max_width=figsize, - height=figsize, - title="focus history", - ) - p2.circle(np.arange(len(self.zHistory)), np.array(self.zHistory) * 1e3) - p2.xaxis.axis_label = "Iteration #" - p2.yaxis.axis_label = "Position [mm]" - # p = vplot(p, p2) - - if hasattr(self, "merit"): # display the merit as well for defocii - p3 = figure( - sizing_mode="stretch_width", - max_width=figsize, - height=figsize, - title="merit TV", - ) - p3.circle(self.dz * 1e3, np.array(self.merit), legend_label="original") - p3.square( - -self.dz * 1e3, - np.array(self.merit), - legend_label="mirrored", - color="red", + ax.scatter(p_new[:, 1], p_new[:, 0], marker="o", s=12, c="red", label="new") + ax.set_aspect("equal") + ax.invert_yaxis() # row 0 at the top, as in the reconstructed image + ax.set_xlabel("column [px]") + ax.set_ylabel("row [px]") + ax.set_title(f'alignment (updated {time.strftime("%Y%h%d, %H:%M:%S")})') + # legend below the axes so it never hides scan points + ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.15), ncol=2, frameon=False) + + if "focus" in axes: # defocus estimate per iteration + ax = axes["focus"] + ax.plot(np.array(self.zHistory) * 1e3, "o", ms=3) + ax.set_xlabel("Iteration #") + ax.set_ylabel("Position [mm]") + ax.set_title("focus history") + + if "tv" in axes: + ax = axes["tv"] + ax.plot(self.TV_history, "s", ms=3) + ax.set_xlabel("Iteration") + ax.set_ylabel("TV score") + ax.set_title("TV history") + + if "merit" in axes: # TV merit per trial defocus, and its mirror image + ax = axes["merit"] + ax.plot(self.dz * 1e3, np.array(self.merit), "o", ms=3, label="original") + ax.plot( + -self.dz * 1e3, np.array(self.merit), "s", ms=3, color="red", + label="mirrored", ) - p3.xaxis.axis_label = "Defocus [mm]" - p3.yaxis.axis_label = "Score [a.u.]" - # p = vplot(p, p3) - if hasattr(self, "TV_history"): - if len(self.TV_history) >= 1: - p4 = figure( - sizing_mode="stretch_width", - max_width=figsize, - height=figsize, - title="TV history", - ) - p4.square(np.arange(len(self.TV_history)), self.TV_history) - p4.xaxis.axis_label = "Iteration" - p4.yaxis.axis_label = "TV score" - # only add the plots that are available - p_list = filter(lambda x: x is not None, [p, p2, p4, p3]) - p = row(*p_list) + ax.set_xlabel("Defocus [mm]") + ax.set_ylabel("Score [a.u.]") + ax.set_title("merit TV") + ax.locator_params(axis="x", nbins=5) # defocus tick labels are long + ax.legend() if saveit: - save( - p, - ) + from pathlib import Path + + output = Path("plots/alignment.png") + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=100) + t1 = time.time() print(f"Alignment display took {t1-t0} secs") + return fig return p def initializeSettings(self): From cdd7f5e53ef59378afe77dd2d04b1fcb066075f4 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 11:58:42 +0200 Subject: [PATCH 08/20] minor sklearn import cleanup --- PtyLab/Reconstruction/CalibrationFPM.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/PtyLab/Reconstruction/CalibrationFPM.py b/PtyLab/Reconstruction/CalibrationFPM.py index 9d3e5069..fdbb04fd 100644 --- a/PtyLab/Reconstruction/CalibrationFPM.py +++ b/PtyLab/Reconstruction/CalibrationFPM.py @@ -10,13 +10,8 @@ from matplotlib import pyplot as plt from copy import deepcopy -try: - from sklearn.cluster import KMeans -except ImportError: - print( - " Could not load sklearn, will not be able to run Fourier Ptychography Calibration" - ) from scipy.ndimage import map_coordinates +from sklearn.cluster import KMeans try: from skimage.filters import window From ccf422483b77467228be7b2623d1ab8725e991bd Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 13:07:48 +0200 Subject: [PATCH 09/20] remove dependency of imageio --- example_scripts/examplePreprocess.py | 6 +++--- tests/ProbeEngines/test_StandardProbe.py | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/example_scripts/examplePreprocess.py b/example_scripts/examplePreprocess.py index 436e320c..7b7436d0 100644 --- a/example_scripts/examplePreprocess.py +++ b/example_scripts/examplePreprocess.py @@ -2,8 +2,8 @@ # PtyLab dataset. import numpy as np import matplotlib.pylab as plt -import imageio import tqdm +from skimage.io import imread from skimage.transform import rescale import glob import os @@ -54,7 +54,7 @@ numFrames = len(framesList) - 1 # read background -dark = imageio.imread("background.tif") +dark = imread("background.tif") # read empty beam (if available) @@ -69,7 +69,7 @@ for k in pbar: # get file name pbar.set_description("reading frame" + framesList[k]) - temp = imageio.imread(framesList[k]).astype("float32") - dark - backgroundOffset + temp = imread(framesList[k]).astype("float32") - dark - backgroundOffset temp[temp < 0] = 0 # todo check if data type is single # crop temp = temp[ diff --git a/tests/ProbeEngines/test_StandardProbe.py b/tests/ProbeEngines/test_StandardProbe.py index 6d7dcc14..5c851119 100644 --- a/tests/ProbeEngines/test_StandardProbe.py +++ b/tests/ProbeEngines/test_StandardProbe.py @@ -1,5 +1,6 @@ import pytest import numpy as np +from skimage import data try: from PtyLab.ProbeEngines.StandardProbe import SHGProbe @@ -9,12 +10,10 @@ pytestmark = pytest.mark.skipif(not HAS_PROBEENGINES, reason="ProbeEngines not ready") -if HAS_PROBEENGINES: - imageio = pytest.importorskip("imageio") - def test_shg_probe_convergence(): - target = imageio.imread("imageio:camera.png").astype(np.float32) + # bundled with scikit-image (no download); same image as imageio's "camera.png" + target = data.camera().astype(np.float32) target = target / np.linalg.norm(target) engine = SHGProbe() engine.probe = np.random.rand(*target.shape) From 21b87a2a8860e9cd24294e500344154e3a9e1569 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 13:15:01 +0200 Subject: [PATCH 10/20] updating documentation info first --- docs/advanced/position-correction.md | 4 +-- docs/getting-started/installation.md | 47 ++++++++++++++++++---------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/advanced/position-correction.md b/docs/advanced/position-correction.md index 8234b5fa..548f5acf 100644 --- a/docs/advanced/position-correction.md +++ b/docs/advanced/position-correction.md @@ -49,9 +49,9 @@ After reconstruction, inspect how much each position was corrected: recon.make_alignment_plot(saveit=False) ``` -This opens an interactive Bokeh plot comparing the original encoder positions to the corrected positions, with arrows showing the displacement magnitude and direction. +This returns a matplotlib figure comparing the original encoder positions (yellow squares) to the corrected positions (red circles), in reconstruction pixels. In a notebook, leave the call as the last line of a cell to display it. When zPIE has run, the figure also shows the focus history and the TV merit per trial defocus. -To save the plot to a file: +To save the plot to `plots/alignment.png`: ```python recon.make_alignment_plot(saveit=True) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 303318f5..9d5c95e1 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,33 +2,50 @@ PtyLab.py requires **Python 3.10–3.13** and is distributed on [PyPI](https://pypi.org/project/ptylab/). -## From PyPI (pip) +## From PyPI + +!!! tip + The commands below use [uv](https://docs.astral.sh/uv/getting-started/installation/) for much faster installs. If you don't have it, drop the `uv` prefix and use plain `pip`. === "CPU" ```bash - pip install ptylab + uv pip install ptylab ``` === "GPU" ```bash - pip install "ptylab[gpu]" + uv pip install "ptylab[gpu]" ``` -!!! tip - For faster installs, use [uv](https://docs.astral.sh/uv/getting-started/installation/): +## Optional extras - ```bash - uv pip install ptylab - ``` +The core install is deliberately small. Features that need heavier packages are opt-in extras: + +| Extra | Installs | Needed for | +|---|---|---| +| `gpu` | CuPy (CUDA 12) | GPU reconstruction | +| `gui` | pyqtgraph, PySide6 | the interactive Qt viewer (`show3Dslider` outside a notebook) | + +Combine them in one command, for example: + +```bash +uv pip install "ptylab[gpu,gui]" +``` + +## Interactive viewer (Qt) + +Outside a notebook, `show3Dslider` opens an interactive pyqtgraph (Qt) viewer when the `gui` extra is installed. Without it, or if Qt cannot start, it falls back to a matplotlib slider. + +On a remote machine, the viewer window needs a graphical connection: a remote desktop session (or equivalent), or X11 forwarding from Linux or macOS (`ssh -X`). Without a display, the matplotlib fallback is used. ## Latest development version To install the unreleased state of `main` directly from GitHub: ```bash -pip install git+https://github.com/PtyLab/PtyLab.py.git +uv pip install git+https://github.com/PtyLab/PtyLab.py.git ``` ## Verify GPU detection @@ -43,25 +60,23 @@ This prints available GPU device information or warns if no GPU is found. ## Development setup -Clone the repository and install with development dependencies: +Clone the repository and install all optional dependencies (GPU, plotting, FPM calibration, docs, tests): ```bash git clone https://github.com/PtyLab/PtyLab.py.git cd PtyLab.py -uv sync --extra dev +uv sync --all-extras ``` +`uv sync` makes the environment match exactly the extras you ask for, so install them all in one command. Running `uv sync --extra gui` afterwards would remove the others. + This creates a `.venv` virtual environment in the project root. Select it in your IDE or activate it: ```bash source .venv/bin/activate ``` -For GPU support during development: - -```bash -uv sync --extra dev,gpu -``` +On a machine without a GPU, `uv sync --all-extras` still works: the CUDA packages are installed but unused. To keep the environment smaller, list only the extras you need, for example `uv sync --extra dev,gui`. ## Running tests From 11600f491fb4f6bf0fa50380e32109165475a374 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 13:16:24 +0200 Subject: [PATCH 11/20] updating dependencies in toml after the cleanup efforts --- pyproject.toml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb814b8b..2a8b34d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,8 @@ dependencies = [ "h5py~=3.9", "scipy~=1.11", "scikit-image>=0.21.0", - "scikit-learn~=1.3", "tqdm~=4.65", - "pyqtgraph~=0.13", - "bokeh~=3.2", - "PyQt5~=5.15", + "scikit-learn~=1.3", "ipython~=8.18", "ipywidgets>=8.1.8", ] @@ -28,10 +25,12 @@ dev = [ "black~=23.7", "ipykernel~=6.25", "pytest~=9.0", - "imageio~=2.0", +] +gui = [ + "pyqtgraph>=0.13", + "PySide6-Essentials>=6.2", # only QtCore/QtGui/QtWidgets ] gpu = ["cupy-cuda12x[ctk]; sys_platform != 'darwin'"] -tensorflow = ["tensorflow~=2.14"] docs = [ "jetblack-markdown", "mkdocs>=1.6.1", From f9ddbace3cfe47710d201c4e688c79b7ce9dadce Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 13:59:27 +0200 Subject: [PATCH 12/20] toml makes few flags optional and for contributor tooling simplifies extras + CI updated with these new infrastructure changes --- .github/workflows/docs.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- mkdocs.yml | 1 + pyproject.toml | 14 +++++++++----- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 409706dd..2f128adb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,10 +24,10 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Install documentation dependencies - run: uv sync --extra docs + run: uv sync --group docs - name: Build documentation - run: uv run mkdocs build --strict + run: uv run --group docs mkdocs build --strict - name: Upload documentation if: github.event_name != 'pull_request' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d39e5bda..29cee9c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -82,8 +82,8 @@ jobs: with: python-version: ${{ matrix.python-version }} cache-dependency-glob: "pyproject.toml" - - name: Install the project with `dev` - run: uv sync --extra dev + - name: Install the project with the `dev` group + run: uv sync - name: Run tests run: uv run pytest tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94c64633..8cafcf86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache-dependency-glob: "pyproject.toml" - - name: Install the project with `dev` - run: uv sync --extra dev + - name: Install the project with the `dev` group + run: uv sync - name: Run tests run: uv run pytest tests \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3d1d8388..9ba42c01 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - "05 – CPM Reflection": tutorials/tutorial_reflectionPtychography.ipynb - "06 – CPM Interferometric Intensity": tutorials/tutorial_interferometricIntensityConstraint.ipynb - "07 – FPM": tutorials/tutorial_FPM.ipynb + - Development: development.md - API Reference: - api/index.md - Core: api/core.md diff --git a/pyproject.toml b/pyproject.toml index 2a8b34d1..f21863ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,16 +21,20 @@ dependencies = [ ] [project.optional-dependencies] -dev = [ - "black~=23.7", - "ipykernel~=6.25", - "pytest~=9.0", -] gui = [ "pyqtgraph>=0.13", "PySide6-Essentials>=6.2", # only QtCore/QtGui/QtWidgets ] gpu = ["cupy-cuda12x[ctk]; sys_platform != 'darwin'"] + +# contributor tooling, not user features: not published as `ptylab[...]` extras. +# `uv sync` installs the `dev` group by default; `docs` is opt-in with `--group docs`. +[dependency-groups] +dev = [ + "black~=23.7", + "ipykernel~=6.25", + "pytest~=9.0", +] docs = [ "jetblack-markdown", "mkdocs>=1.6.1", From 7ed600897800e9868de619db269a4469dd8df3f0 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 13:59:41 +0200 Subject: [PATCH 13/20] minor comment change --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f21863ec..d423de15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ gui = [ ] gpu = ["cupy-cuda12x[ctk]; sys_platform != 'darwin'"] -# contributor tooling, not user features: not published as `ptylab[...]` extras. # `uv sync` installs the `dev` group by default; `docs` is opt-in with `--group docs`. [dependency-groups] dev = [ From 7bd0545036bba3eb1da5287022badb1681806d58 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:00:13 +0200 Subject: [PATCH 14/20] readme simplified to make it smaller and points directly to the documentation --- README.md | 40 ++++++---------------------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 218dbfd6..127c0fd7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Tests](https://github.com/PtyLab/PtyLab.py/actions/workflows/test.yml/badge.svg) [![Docs](https://github.com/PtyLab/PtyLab.py/actions/workflows/docs.yml/badge.svg)](https://ptylab.github.io/PtyLab.py/) -[**Getting Started**](#getting-started) | [**Installation**](#installation) | [**Development**](#development) | [**Documentation**](https://ptylab.github.io/PtyLab.py/) +[**Key Features**](#key-features) | [**Getting Started**](#getting-started) | [**Installation**](#installation) | [**Documentation**](https://ptylab.github.io/PtyLab.py/) PtyLab is an inverse modeling toolbox for Conventional (CP) and Fourier (FP) ptychography in a unified framework. For more information please check the [paper](https://opg.optica.org/oe/fulltext.cfm?uri=oe-31-9-13763&id=529026). @@ -43,46 +43,18 @@ Install from PyPI within your virtual environment: ```bash pip install ptylab ``` -For much faster installs, we recommend [uv](https://docs.astral.sh/uv/getting-started/installation/): `uv pip install ptylab` +For faster installs, we recommend [uv](https://docs.astral.sh/uv/getting-started/installation/): `uv pip install ptylab` -This package uses `cupy` to utilize GPU for faster reconstruction. To enable GPU support: +To utilize GPU for faster reconstruction, enable GPU support: ```bash -pip install "ptylab[gpu]" -``` -You can check if GPU is detected with `ptylab check gpu`. - -For the latest unreleased changes on `main`: - -```bash -pip install git+https://github.com/PtyLab/PtyLab.py.git +uv pip install "ptylab[gpu]" # supports CUDA 12 ``` +Check whether GPU is correctly configured with `uv run ptylab check gpu`. For additional information, check the [installation guide](https://ptylab.github.io/PtyLab.py/getting-started/installation/). ### Development -Clone the repo and install dev and gpu dependencies with [uv](https://docs.astral.sh/uv/getting-started/installation/): - -```bash -git clone git@github.com:PtyLab/PtyLab.py.git -cd PtyLab.py -uv sync --extra dev,gpu # remove the GPU flag if you are on CPU -``` -This creates a `.venv` virtual environment in the project root. Select this environment from your IDE. - - -Add tests for new implementations and run the suite: - -```bash -uv run pytest tests -``` - -### Documentation - -Any documentation changes would be deployed automatically after a PR merges main. However, please verify locally first with - -```bash -uv run --extra docs mkdocs serve -``` +For the development setup, running the tests and building the documentation locally, see the [development guide](https://ptylab.github.io/PtyLab.py/development/) in the documentation. ## Citation From bce47e58465a1ce93a4b4a449a82c70ba34fbe13 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:00:36 +0200 Subject: [PATCH 15/20] main docs updated to separate development workflow --- docs/development.md | 40 +++++++++++++++++++++ docs/getting-started/installation.md | 53 +++++++--------------------- 2 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 docs/development.md diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..90d377db --- /dev/null +++ b/docs/development.md @@ -0,0 +1,40 @@ +# Development + +This page is for contributors working on PtyLab.py itself. To use PtyLab, see [Installation](getting-started/installation.md). + +## Setup + +Clone the repository and install it with [uv](https://docs.astral.sh/uv/getting-started/installation/). `uv sync` always includes the development tools (pytest, ipykernel); add the extras for your machine: + +```bash +git clone git@github.com:PtyLab/PtyLab.py.git +cd PtyLab.py +uv sync --all-extras # GPU machine: gpu + gui +uv sync --all-extras --no-extra gpu # CPU-only machine +``` + +This creates a `.venv` virtual environment in the project root. Select it in your IDE or activate it: + +```bash +source .venv/bin/activate +``` + +## Running tests + +Add tests for new implementations and run the suite with: + +```bash +uv run pytest tests +``` + +Tests that need an unavailable dependency are skipped, for example the CUDA tests on a machine without a GPU. Check the skipped count in the summary to see what was not exercised. + +## Serving documentation locally + +Documentation changes are deployed automatically once a pull request is merged into `main`. Check them locally first: + +```bash +uv run --group docs mkdocs serve +``` + +Then open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. The page reloads when you save a change. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 9d5c95e1..07931635 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,6 +1,6 @@ # Installation -PtyLab.py requires **Python 3.10–3.13** and is distributed on [PyPI](https://pypi.org/project/ptylab/). +PtyLab.py requires **Python 3.10–3.13** and is distributed on [PyPI](https://pypi.org/project/ptylab/). Install it within a virtual environment. ## From PyPI @@ -19,16 +19,18 @@ PtyLab.py requires **Python 3.10–3.13** and is distributed on [PyPI](https://p uv pip install "ptylab[gpu]" ``` +The `gpu` extra installs [CuPy](https://cupy.dev/) for CUDA 12 and enables GPU acceleration of the reconstruction engines. CUDA 13 is not supported yet. The extra is not installed on macOS, where PtyLab runs on the CPU. + ## Optional extras -The core install is deliberately small. Features that need heavier packages are opt-in extras: +Some features can be installed with the optional dependencies with the following extras: | Extra | Installs | Needed for | |---|---|---| | `gpu` | CuPy (CUDA 12) | GPU reconstruction | | `gui` | pyqtgraph, PySide6 | the interactive Qt viewer (`show3Dslider` outside a notebook) | -Combine them in one command, for example: +For example to combine them in one command: ```bash uv pip install "ptylab[gpu,gui]" @@ -40,55 +42,26 @@ Outside a notebook, `show3Dslider` opens an interactive pyqtgraph (Qt) viewer wh On a remote machine, the viewer window needs a graphical connection: a remote desktop session (or equivalent), or X11 forwarding from Linux or macOS (`ssh -X`). Without a display, the matplotlib fallback is used. -## Latest development version +## Latest unreleased version -To install the unreleased state of `main` directly from GitHub: +To install the latest unreleased changes on `main` directly from GitHub: ```bash uv pip install git+https://github.com/PtyLab/PtyLab.py.git ``` -## Verify GPU detection - -After installing with a CUDA extra, confirm the GPU is detected: - -```bash -ptylab check gpu -``` - -This prints available GPU device information or warns if no GPU is found. - -## Development setup - -Clone the repository and install all optional dependencies (GPU, plotting, FPM calibration, docs, tests): +Extras work the same way, for example with GPU support: ```bash -git clone https://github.com/PtyLab/PtyLab.py.git -cd PtyLab.py -uv sync --all-extras +uv pip install "ptylab[gpu] @ git+https://github.com/PtyLab/PtyLab.py.git" ``` -`uv sync` makes the environment match exactly the extras you ask for, so install them all in one command. Running `uv sync --extra gui` afterwards would remove the others. - -This creates a `.venv` virtual environment in the project root. Select it in your IDE or activate it: - -```bash -source .venv/bin/activate -``` - -On a machine without a GPU, `uv sync --all-extras` still works: the CUDA packages are installed but unused. To keep the environment smaller, list only the extras you need, for example `uv sync --extra dev,gui`. - -## Running tests - -```bash -uv run pytest tests -``` +## Verify GPU detection -## Serving documentation locally +After installing with the `gpu` extra, check whether the GPU is correctly configured: ```bash -uv sync --extra docs -uv run mkdocs serve +uv run ptylab check gpu ``` -Then open [http://127.0.0.1:8000](http://127.0.0.1:8000) in your browser. +This prints the CuPy CUDA device information, or warns if no GPU is found. From 048f929757ed1a7856aa81311db0f2aac289b2f7 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:12:05 +0200 Subject: [PATCH 16/20] removes some dead jax dependency use. The function position_update_to_change_in_z needs a closer look for verification, but it isn't used regardless at the moment --- PtyLab/Engines/BaseEngine.py | 56 +++++++++++------------------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/PtyLab/Engines/BaseEngine.py b/PtyLab/Engines/BaseEngine.py index 60335a69..93634afc 100644 --- a/PtyLab/Engines/BaseEngine.py +++ b/PtyLab/Engines/BaseEngine.py @@ -1919,8 +1919,15 @@ def position_update_to_change_in_z(self, loop): z_{\mathrm{target}} = \frac{z}{s} $$ - The distance update is filtered through an Adam optimizer before being - applied to `reconstruction.zo`. After updating the propagation distance, + Rather than jumping to $z_{\mathrm{target}}$, the distance moves a fixed + fraction $\alpha = 0.2$ of the way there on each call, so that noisy position + estimates do not make it jump: + + $$ + z \leftarrow z + \alpha \, (z_{\mathrm{target}} - z) + $$ + + After updating the propagation distance, the corrected scan positions are rescaled around their center so that the global scale change is transferred from the position correction to the propagation distance. @@ -1932,25 +1939,7 @@ def position_update_to_change_in_z(self, loop): Notes: This method is used together with position correction and is called periodically when `map_position_to_z_change` is enabled. - - The method requires JAX, which is imported when the function is - called. """ - import jax - from jax.experimental import optimizers - - if not hasattr(self, "optlib"): - self.i_z_optimizer = 0 - # from itertools import count - # count - op_init, op_update, op_get = optimizers.adam(3e-3) - state = op_init(self.reconstruction.zo) - self.optlib = {"op_update": op_update, "op_get": op_get, "state": state} - else: - state = self.optlib["state"] - op_get = self.optlib["op_get"] - op_update = self.optlib["op_update"] - X0 = self.reconstruction.encoder_corrected Y0 = self.experimentalData.encoder msqdisplacement = np.linalg.norm(1e6 * X0 - 1e6 * Y0) @@ -1962,27 +1951,16 @@ def position_update_to_change_in_z(self, loop): # now, find the scaling with respect to the original one factor = np.std(X0) / np.std(Y0) - # update z - new_z = self.reconstruction.zo / factor - step = new_z - self.reconstruction.zo - self.logger.info(f"Naive estimate of new z: {new_z:.3f}, stepsize {step:.3f}") - step = 5 * step - # check if the thing should be updated. - if abs(step) < 1e-4: # if it's too small, just truncate it, - # it may be that the distance changed due to some other update. - # Take that into account as if we don't the steps will be super large. - self.i_z_optimizer += 1 - step = self.reconstruction.zo - op_get(state) - self.optlib["state"] = op_update(self.i_z_optimizer, -step, state) - + # target distance implied by the scale change of the positions + target_z = self.reconstruction.zo / factor + step = target_z - self.reconstruction.zo + self.logger.info(f"Naive estimate of new z: {target_z:.3f}, stepsize {step:.3f}") + if abs(step) < 2e-5: # same skip threshold as before (it used 5 * step < 1e-4) self.logger.info("Skipping update as step is too small") - # as we're only updating it for sake of good measure, we don't have to update anything else. return - # now, as we're actually updating, we can increase the step - self.i_z_optimizer += 1 - self.optlib["state"] = op_update(self.i_z_optimizer, -step, state) - # get the new value - z_new = float(jax.device_get(op_get(self.optlib["state"]))) + # damped update: move a fraction of the way toward the target each call + damping = 0.2 + z_new = self.reconstruction.zo + damping * step self.logger.info(f"Loop: {loop} step: {step}") self.logger.info( From 509f8870598f15b8eb68ab487793aec812a3d332 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:14:27 +0200 Subject: [PATCH 17/20] build updated --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d423de15..814db90e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ ptylab = "PtyLab.utils.cli:main" testpaths = ["tests"] [build-system] -requires = ["uv_build>=0.6.1,<0.12"] +requires = ["uv_build>=0.12.5,<0.13.0"] build-backend = "uv_build" [tool.uv.build-backend] From 19841dc4dc664115168f599626580456b2efffba Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:16:35 +0200 Subject: [PATCH 18/20] automated check for GPU --- tests/Operators/test_operators_integration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Operators/test_operators_integration.py b/tests/Operators/test_operators_integration.py index 5d9962f6..3e9b373f 100644 --- a/tests/Operators/test_operators_integration.py +++ b/tests/Operators/test_operators_integration.py @@ -6,7 +6,8 @@ try: import cupy as cp - HAS_GPU = True + # cupy can be installed on a machine without a GPU (e.g. via --all-extras) + HAS_GPU = cp.cuda.is_available() except ImportError: cp = None HAS_GPU = False From b5e86b2cdc0d1ce82c987862a87272cdc92d9a03 Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:20:41 +0200 Subject: [PATCH 19/20] adding ruff instead of black as a dependency --- docs/development.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 90d377db..d3717304 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,6 +19,14 @@ This creates a `.venv` virtual environment in the project root. Select it in you source .venv/bin/activate ``` +## Code style + +Format the files you changed with [ruff](https://docs.astral.sh/ruff/): + +```bash +uv run ruff format path/to/changed_file.py +``` + ## Running tests Add tests for new implementations and run the suite with: diff --git a/pyproject.toml b/pyproject.toml index 814db90e..8dbd3512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ gpu = ["cupy-cuda12x[ctk]; sys_platform != 'darwin'"] # `uv sync` installs the `dev` group by default; `docs` is opt-in with `--group docs`. [dependency-groups] dev = [ - "black~=23.7", + "ruff>=0.16", "ipykernel~=6.25", "pytest~=9.0", ] From 1698fdae282fc346759f0164658fead3421c4f0e Mon Sep 17 00:00:00 2001 From: Shantanu Kodgirwar Date: Fri, 9 Oct 2026 14:21:01 +0200 Subject: [PATCH 20/20] updating install instructions --- docs/advanced/gpu.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/advanced/gpu.md b/docs/advanced/gpu.md index f5f37cb9..35a3ad21 100644 --- a/docs/advanced/gpu.md +++ b/docs/advanced/gpu.md @@ -7,7 +7,7 @@ PtyLab.py uses [CuPy](https://cupy.dev/) as a drop-in GPU backend for NumPy. The GPU acceleration requires installing PtyLab.py with a CUDA extra. See [Installation](../getting-started/installation.md) for the full instructions. ```bash -pip install "ptylab[gpu]" +uv pip install "ptylab[gpu]" ``` ## Verifying GPU availability @@ -15,7 +15,7 @@ pip install "ptylab[gpu]" From the command line (within your environment): ```bash -ptylab check gpu +uv run ptylab check gpu ``` ## Toggle GPU switch