Skip to content

Port the visualizer to PyQt6 and fix its bugs - #74

Merged
HazenBabcock merged 10 commits into
ZhuangLab:masterfrom
HazenBabcock:visualizer-pyqt6
Nov 11, 2026
Merged

HazenBabcock merged 10 commits into
ZhuangLab:masterfrom
HazenBabcock:visualizer-pyqt6

Conversation

@HazenBabcock

Copy link
Copy Markdown
Member

Summary

Ports storm_analysis/visualizer from PyQt5 to PyQt6 (6.12), and fixes the problems found along the way.

  • PyQt6: scoped enums, | for render hints, QMouseEvent.position(), visualizer_ui.py regenerated with pyuic6. The visualizer is now a package, so python -m storm_analysis.visualizer.visualizer works from any directory.

  • Click selection: a click selected the localization one pixel down and to the right of the one under the mouse whenever there was one. This dates from the switch to zero-based HDF5 coordinates.

    -dx = self.locs["x"] - px - 0.5
    +dx = self.locs["x"] - (px - 0.5)
  • Contrast changes: these no longer clear the scene, so the circles stay put and a click right after a change no longer hits deleted items, which used to abort the program.

     handleRangeChange
    -  displayFrame(False)          # re-read the frame, scene.clear() deletes the circles
    -  start the 100 ms timer       # circles reloaded from the file
    +  movie_view.setContrast()     # redraw the pixmap only
  • Errors: a movie or localization file that can't be read is reported in a message box instead of aborting. The program also installs an exception hook for anything else.

  • Smaller fixes:

    • The movie file is closed when replaced and on exit.
    • Equal contrast limits threshold the image instead of dividing by zero.
    • Integers of any width are shown as integers.
  • Read-only HDF5: localization files are opened read-only (SAH5Reader), so a loaded file no longer locks other programs out. sa_h5py.isSAHDF5() now opens files read-only as well. It used to open them read-write, so it returned False whenever another process had the file open.

  • New behavior:

    • The info table shows each file's own fields, for example DECODE's score or ground-truth intensity.
    • A new movie is fitted to the view.
    • Frame Capture asks where to save.
    • The contrast slider runs from 0 to the smallest power of 2 (at least 256) that covers the brightest pixel shown so far and the max setting. It was fixed at 0–100000.
  • Docs and packaging:

    • The README and install docs list PyQt6.
    • pyproject.toml gains a gui extra (pip install .[gui]).
    • Dead code is removed.

Evidence

The new tests in storm_analysis/test/test_visualizer.py drive the GUI on Qt's offscreen platform. Most were written before their fix and seen to fail on the ported code:

Test Before After
click on a circle center assert '11.000' == '10.000' (neighbor selected) pass
click after a contrast change RuntimeError: wrapped C/C++ object of type MoleculeItem has been deleted pass
unreadable movie OSError: only .dax, .spe and .tif are supported (aborts the GUI) reported, pass
replaced movie closed fileptr still open pass
int64 field '7.000' == '7' pass
another process reads a loaded file BlockingIOError: unable to lock file pass

On test.dax with test_ref.hdf5, 5 of 200 clicks on circle centers selected the wrong localization before the fix.

The full suite gives 306 passed locally. The 12 visualizer tests need PyQt6, and the file is skipped without it. CI doesn't install PyQt6, so CI does not run them. I also checked the window on a real X display, and on the SMLM challenge AS-Exp movies with Spliner, DECODE and ground-truth HDF5 files.

Merge Danger

Door: two-way

Blast Radius: visualizer

The visualizer now requires PyQt6 and no longer runs on PyQt5. multi_plane/mapper.py, which is deprecated, still uses PyQt5 and is unchanged. Outside the visualizer, the only change is that isSAHDF5() opens files read-only. It gives the same answer, except that it now says True for a valid file another process has open, where it used to say False. fitting.py is its only other caller.

🤖 Generated with Claude Code

HazenBabcock and others added 10 commits November 10, 2026 19:28
Use the scoped enum names, combine render hints with | rather than +,
read mouse positions through position() since QMouseEvent.x() and y()
are gone, and regenerate visualizer_ui.py with pyuic6 so that QAction
comes from QtGui.

Add an __init__.py and import the two helper modules through the
package, so the visualizer runs as
python -m storm_analysis.visualizer.visualizer from any directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getClosest compared the localizations with the click position plus half
a pixel, but the circles are drawn at the click position minus half a
pixel, so a click on the center of a circle found the localization one
pixel down and to the right of it whenever there was one. This dates
from the switch to zero based HDF5 coordinates, which updated the
drawing but not the lookup.

Also reset the selection index whenever the localization items are
rebuilt, as the old index refers to the previous items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every contrast change cleared the scene, which deleted the localization
items, and read the frame from the movie again. The items came back
from the localization file 100 ms later, so they flickered while the
slider moved, and a click in that window raised a RuntimeError on the
deleted items, which PyQt turns into an abort.

The view now keeps the frame and a single pixmap item, and has separate
methods for a new frame, a new contrast and new localizations. A
contrast change only redraws the pixmap. As the scene is no longer
cleared its rectangle is set from the frame size, so that it shrinks
for a smaller movie.

Equal contrast limits used to divide by zero, they now threshold the
image. The frame is also drawn as an 8 bit grayscale image rather than
being expanded to RGB32 first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyQt aborts the program when a slot raises an exception that nothing
catches, so choosing a movie or a localization file that couldn't be
read closed the visualizer. Loading now reports the problem in a
message box and keeps whatever was loaded before. A movie counts as
readable once its first frame loads. For anything else, the program
installs an exception hook that prints the exception and shows it
rather than aborting.

Also close the movie file when it is replaced and on exit, and show
integer fields as integers whatever their width, not just int32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A newly loaded movie is now zoomed to fill the view. Before, the view
started at 2x and was reset to 1x on the first load, so a small movie
was a small patch in the middle of the window and a large one needed
scrolling.

Frame Capture used to overwrite capture.png in whatever directory the
visualizer was started from. It now opens a save dialog in the current
data directory, suggesting the movie name and frame number, and reports
the saved file and its size in the status bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the commented out key press signal and the keyPressEvent that
only called the base class, an unused attribute, and a disabled debug
print in the range slider. InfoTable is now a plain object as it only
wraps a QTableWidget. MoleculeItem's pens are renamed so that they no
longer hide QGraphicsEllipseItem.pen().

The visualizer README described PyQt4 and .bin files only. The other
docs list PyQt6 as an optional dependency for the visualizer, and the
note about making a Windows virtual environment that works with PyQt5
is gone. pyproject.toml gains a gui extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nine tests drive the GUI on Qt's offscreen platform: loading and
stepping through a movie, which localization a click selects, clicking
after a contrast change, equal contrast limits, files that can't be
read, closing a replaced movie, integer fields, fitting the view, and
frame capture. They need PyQt6 and the file is skipped without it.

Window takes an optional QSettings so that the tests can give it their
own file. QSettings.setDefaultFormat() and setPath() can't redirect
QSettings("Zhuang Lab", "visualizer"), which always uses the native
format, so without this the tests would read and overwrite the user's
own visualizer settings.

Update the test count in AGENTS.md, which was already behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The slider ran from 0 to 100000, the limit of the spin boxes, so for a
typical camera the useful range was a sliver at the bottom of it. It now
runs from 0 to the smallest power of 2, and at least 256, that covers
both the brightest pixel shown so far and the maximum contrast setting.

Finding the brightest pixel costs one max() of each frame as it is
shown, the movie is not read in advance. The range grows when a frame
is brighter or a larger maximum is typed in, never shrinks while a
movie is open so that the bars don't move as you step through it, and
starts again with each movie.

QRangeSlider.setRange() now updates the display, so the range can be
changed after the slider is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table had a fixed list of fields per file type, so fields from other
programs never appeared, such as DECODE's score and uncertainties or the
SMLM challenge's ground truth intensity, and fields a file didn't have
were listed as "na". The rows now come from the selected localization:
x, y and z first, then the rest in alphabetical order. The table is
empty until something is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The visualizer opened HDF5 localization files with SAH5Py, which opens
them for writing, so while they were loaded HDF5's file lock stopped any
other program from reading them. It now uses SAH5Reader.

isSAHDF5() also opened the file for writing just to check its
attributes, which fails whenever another process has the file open. It
then returned False, and the visualizer went on to read the HDF5 file as
an Insight3 file. It now opens the file read only too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HazenBabcock
HazenBabcock merged commit 927f4a2 into ZhuangLab:master Nov 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant