ROIapp (Python)
Python port of the MATLAB App Designer ROI-selector GUI
(ROIapp_matlab/roiSelectApp-main/app2.mlapp) used to hand-curate ROIs (cell
footprints) from 2-photon calcium-imaging movies: click a pixel, grow a mask
via correlation- or fluorescence-based region growing, review/dilate/contract
it, stage it into a "temp" and then a "curated" set, and save the result as a
.mat file for the broader MATLAB analysis pipeline (analyze_ASC-utils).
app1.mlapp (a strict subset of app2) was not ported separately -- app2's
functionality is a superset, so nothing is lost.
This single pip package also bundles movieslider (Python port of the
MATLAB MovieSlider widget -- smooth movie playback/scrubbing/contrast),
which backs the Play Movie button (see "Run" below). It lives at the
top-level movieslider/ package alongside roiapp/, built and installed
together from this one pyproject.toml -- originally developed as the
standalone MovieSlider_python project, later merged in here so
pip install gets everything in one step.
Setup
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
Requires system support for python3-venv (Debian/Ubuntu:
sudo apt install python3.13-venv, adjusted for your Python version) and a
Qt-capable environment (a real display, or QT_QPA_PLATFORM=offscreen for
headless use).
Run
.venv/bin/roiapp # console script
python3 -m roiapp # equivalent, module form
movieslider's own standalone viewer (File > Open, independent of ROIapp)
is also installed as a second console script / module:
.venv/bin/movieslider or python3 -m movieslider.
Use the Load File / Load Folder buttons to open a movie:
- Load File autodetects TIFF (
.tif/.tiff), FITS (.fits/.fit/.fts), or MAT (.mat) by extension, falling back to magic-byte sniffing for unrecognized extensions. A.matfile's largest top-level 3-D numeric array is taken as the movie. - Load Folder treats every TIFF file in the chosen folder as one frame (or chunk of frames) of a single movie, concatenated in natural (digit-aware) filename order.
See roiapp/io/movie_loader.py for the loader implementation. This replaces
the original MATLAB app's MONKEYDIR/Day/Rec/"Data to load" dropdown
convention, which assumed a specific lab directory layout -- the Python port
instead loads whatever file or folder the user points it at.
Mean/median/variance/Fano-factor/95th-percentile/local-correlation
projection images are computed from the loaded movie and cached next to it
(a <name>.roiapp_projections.mat sidecar, invalidated automatically if the
source file/folder changes -- see roiapp/io/projection_cache.py).
"Save ROIs" writes an auto-incrementing <name>.manual_rois_v<N>.mat next to
a file source, or inside a folder source (named after the folder).
Play Movie opens the loaded movie in a MovieSlider_python viewer
window (movieslider.gui.movie_slider_widget.MovieSliderWidget, that
project's smooth-playback/scrubbing/contrast widget) -- gui/main_window.py's
_on_play_movie reuses a single player window across clicks rather than
opening a new one each time, and warns instead if no movie is loaded yet.
The neuropil ring size used for background subtraction (previously inferred
from a MONKEYDIR path substring in the MATLAB app) is now an explicit
Small/Large control next to "Bound radius". Each segmentation tab's
"Thresh. Auto Method" also has a "None (manual)" option (beyond app2.m's
own two) that keeps a manually-set threshold sticky across clicks, instead
of app2.m's default behavior of silently resetting to auto-threshold-search
on every fresh click.
Debugging aid: the "Image view" dropdown includes a "Local Corr" option
(no MATLAB equivalent) showing, per pixel, the Pearson correlation of its
own timeseries with the mean timeseries of its up-to-8 spatial neighbors --
a standard technique (also used by Suite2p/CaImAn) for visually surfacing
real cell footprints, useful for finding good seed pixels when Corr-tab
auto-segmentation looks too aggressive. The left control panel has a
colorbar (below the ROI counts) showing the exact value scale for whatever
projection/contrast is currently displayed -- shared between both FOV
panels (gui/colorbar_panel.py) rather than duplicated per panel, since
temp/curated always show the same projection and levels. This also
sidesteps a real layout problem an earlier, per-panel-embedded colorbar
had: pairing a colorbar with an aspect-locked ViewBox in the same
pyqtgraph layout row means lockAspect's letterboxing/pillarboxing (for a
non-square image, or a panel whose cell isn't the same aspect as the
image) leaves the colorbar visibly taller than the actually-displayed
image, and attempts to fix that by shrinking the shared row shrank the
image too (see gui/colorbar_panel.py's module docstring for the full
history) -- living in its own widget outside any image's layout row
removes the conflict entirely, and each FOV panel is now image-only,
using the full available space.
The Corr tab also has a "3x3 block seed" checkbox (no MATLAB
equivalent): by default, a click correlates every nearby pixel against the
single clicked pixel's raw timeseries, exactly matching
corrBasedMasks_new.m; checking this instead averages a 3x3 block centered
on the click into the seed trace first. A single pixel's raw trace can
decorrelate with distance much faster than the (neighbor-averaged) Local
Corr view suggests it should -- the block-averaged seed is less noisy and
recovers more of a cell's true extent at the same threshold (see
algorithms/corr_based_masks.py's _extract_seed_trace).
Building this surfaced one more real bug: the single-clicked-pixel seed's
self-correlation is always exactly 1.0, so the auto-threshold sweep's
lowest candidate (0.6) always captures at least that one pixel -- but a
block-averaged seed has no such guarantee, and on weakly-correlated data
every candidate's mask can legitimately come back empty, making the
auto-threshold objective all-NaN. np.nanargmax raises on an all-NaN
slice; real MATLAB's max([NaN,NaN,NaN]) instead returns ind=1 (no
error) -- confirmed directly against MATLAB R2025a. This previously crashed
the click handler silently (Qt swallows slot exceptions by default, so it
looked exactly like nothing happened), fixed via
algorithms/seed_region.py's matlab_style_nanargmax and a defensive
error dialog now wrapping MainWindow._on_pixel_clicked.
Even a block-averaged seed is still just one fixed reference point --
correlation with it necessarily decays with distance, no matter how clean
the seed trace itself is, which is structurally different from Local Corr
(a moving, per-pixel reference that only requires each pixel to correlate
with its own immediate neighbors, letting correlation "chain" across an
extended region). A first attempt at closing that gap added a "Flood-fill
growth" checkbox: instead of thresholding one fixed correlation map, it
starts from the seed and iteratively grows -- each round, every pixel
adjacent to the current mask is tested against the current mask's own mean
trace (which gets less noisy, not just the seed, as the mask grows), and
whichever pass threshold get added, repeating until nothing new qualifies
(see algorithms/corr_based_masks.py's _flood_fill_corr_mask). Growth is
bounded only by the search disk (max_dist) -- there's no other limit once
the growing mean becomes a generically clean local signal, so on data with
substantial shared background/neuropil contamination this can in principle
grow to fill the whole disk; that's real, observed behavior to threshold
against, not silently capped. Verified on real data: at the same threshold,
flood-fill recovered 155px vs fixed-seed's 141px (Local Corr's own
thresholded blob at that location: 238px) -- and on a deterministic
synthetic test (an AR(1) spatial correlation chain, where adjacent
positions are always strongly correlated but a fixed reference's
correlation decays geometrically with distance) flood-fill recovers the
entire chain while fixed-seed reaches less than half of it.
Flood-fill still visibly disagreed with Local Corr in practice, though --
its growing reference is still fundamentally one trace (even if it
evolves), not Local Corr's fully local, per-pixel one. So the Corr tab's
default growth method is now local_corr_threshold: it thresholds the
already-computed Local Corr image directly and takes the connected
component containing the click (reusing the same island-removal/hole-fill
logic as every other method) -- no new correlation is computed from the
click at all. This reproduces exactly what the Local Corr debugging view
shows, by construction (verified: the resulting mask is byte-for-byte the
thresholded, click-connected blob of the Local Corr image, at every
threshold tested). fixed_seed is no longer reachable from the GUI (only
via direct API calls, e.g. tests wanting strict MATLAB fidelity); the
"Flood-fill growth" checkbox now toggles between local_corr_threshold
(unchecked, default) and flood_fill (checked).
Test
.venv/bin/pytest
Tests run fully headless (QT_QPA_PLATFORM=offscreen, set automatically in
tests/conftest.py) against a small, deterministic synthetic 2P movie with
known ground-truth cell footprints (roiapp/testing_utils/synthetic_data.py,
written out as synthetic TIFF/FITS files/folders for the loader tests) --
no real data is required.
Architecture
roiapp/model/-- pure dataclasses (AppState,ROICollection,ROIParams, ...), no Qt or file I/O dependency.roiapp/algorithms/-- numpy/scipy ports of the MATLAB segmentation math (cleanROI.m,corrBasedMasks_new.m,fluorBasedMasks_new.m,dilate_constrict_ROI.m, neuropil ring construction, trace extraction), unit-testable without Qt or files.roiapp/io/-- movie loading (movie_loader.py: TIFF/FITS/MAT autodetection, folder-of-TIFFs concatenation), projection-image caching (projection_cache.py),.matread/write (including full-v7.3struct/cell-array round-tripping), Suite2p import, legacy and modernmanual_rois*.matload/save.roiapp/gui/controller.py--AppController: the full app2.m callback set as headless, Qt-free business logic, exercised directly by pytest.roiapp/gui/*.py(everything else) -- PySide6 + pyqtgraph widgets and the signal wiring connecting them toAppController.movieslider/-- a second, independent GUI package (Python port of the MATLABMovieSliderwidget) bundled in this same distribution;roiapponly touches it at one point,gui/main_window.py's_on_play_movie. Seemovieslider/'s own module docstrings for its architecture (model/,playback/,algorithms/,io/,gui/mirror the same layering convention asroiapp/'s). Its tests live undertests/movieslider/rather than mixed intotests/gui/,tests/io/, etc., to avoid filename collisions withroiapp's own tests (both packages have anio/movie_loader.py, for instance).
Known deliberate deviations from the MATLAB source
Found while tracing exact source semantics; each documented at its call site and covered by a regression test:
- The FOV display's mean/median/var/fano/pct95/local-corr transpose
(
algorithms/projections.py): app2.m displaysapp.data.mean(etc.) transposed relative to the raw movie, but feeds a mouse click's coordinates from that transposed display straight intocorrBasedMasks_new/fluorBasedMasks_new, which index the untransposed raw movie directly -- so clicking a cell that's clearly visible on screen seeds the correlation/fluorescence search at the wrong pixel unless the image happens to be symmetric about its diagonal (never true for real data). Confirmed directly against real MATLAB with a synthetic asymmetric test image. This affects the existing MATLAB app too, not just the port -- fixed here by simply not replicating the transpose, so the FOV display, mouse clicks, mask growth, and overlay rendering all share one coordinate system with no compensating swap needed anywhere. See "A real bug this caught" below for the full story. selectROI's temp/curated trace mixup (gui/controller.py): app2.m, when updating an existing temp ROI, wrote the recomputed trace intocurROIs.Fall/Fneuinstead oftempROIs.Fall/Fneu-- a copy-paste bug that can silently corrupt an unrelated curated ROI's saved trace. This affects the existing MATLAB app too, not just the port.deleteCurROI's sourcemat color-erase (gui/controller.py): missing the explicitcolor=[0,0,0]argument its siblingdeleteTempROIuses -- cosmetic only (sourcemat is a display aid, never saved).- The current-mask overlay's alpha scale (
gui/overlay_render.py): a mask virtually always covers <1% of a full frame, makingscale = 0.5/quantile(scaled,0.99)legitimatelyInf(clamped to fully opaque) in the overwhelmingly common case -- handled vianan_to_num(posinf=1.0, nan=0.0)rather than a naive divide-by-zero guard. - A few MATLAB source quirks are preserved faithfully rather than "fixed"
since they don't corrupt data (e.g.
cleanROI's area-band component filtering is position-independent, not distance-based; the fluor segmentation'scellnpdecorrobjective isabs(correlation)while the corr segmentation's is-correlation; state-button dilate/contract fires on both press and release) -- see the relevant module docstrings for the full list.
Real-data validation (Phase 7)
Validated against a real 500x500x500 (uint16) 2-photon movie and, in an
environment with MATLAB R2025a available, directly against the original
MATLAB source (corrBasedMasks_new.m, fluorBasedMasks_new.m,
computeImageMean.m, strel('sphere',r)+imdilate/imerode, prctile),
not just Python-side round-tripping:
.matv7.3 write fidelity: a Python-savedmanual_rois_v2.mat(real 500x500x5 masks, Nx1 cell-array-of-structsparams, nested cell-arrayroi_history) loads in real MATLAB with every field intact.strel('sphere', r)vsalgorithms/morphology.py's explicit Euclidean-disk footprint: pixel-for-pixel identical toimdilate/imerodefor r in {1,2,3,4,8} (both dilate and erode).prctile/quantilevsnumpy.percentile(..., method='hazen'): identical to floating-point precision (1e-14).corrBasedMasks_new.mvsalgorithms/corr_based_masks.py: pixel-for- pixel identical mask on real data at an explicit threshold.fluorBasedMasks_new.mvsalgorithms/fluor_based_masks.py: pixel-for-pixel identical mask on real data, after fixing a real bug this comparison caught (below).
Two real bugs this caught, one leading to the other
First, an earlier version of this port initially returned the raw,
untransposed projection values, then was "corrected" to match app2.m's
LoaddataButtonPushed, which calls computeImageMean(...)' (and
identically for median/var/fano/pct95) -- an explicit trailing transpose, in
both the cache-hit and fresh-compute branches -- after confirming that
transposed value matches real MATLAB's app.data.mean to floating-point
precision. Every synthetic test passed throughout, because a global
transpose applied consistently doesn't break Python-to-Python
self-consistency checks; it only showed up as a real mismatch against
actual MATLAB output on real, spatially-asymmetric imaging data.
Second, much later, matching that transpose byte-for-byte turned out to
be the wrong call: app2.m's mouseClick reads a click's currentLoc from
that same transposed app.data.mean display, then feeds it directly into
corrBasedMasks_new/fluorBasedMasks_new, which index the untransposed
datamap.Data(1).IMG with it -- so clicking a cell that's clearly visible
on screen seeds the correlation/fluorescence search at the wrong raw
pixel, unless the image happens to be symmetric about its diagonal (never
true for real data). Confirmed directly against real MATLAB: a synthetic
cell placed at raw (row 11, col 51) appeared on MATLAB's own displayed
app.data.mean at (row 50, col 12), and clicking there (mirroring
mouseClick's real behavior) produced a degenerate 1-pixel mask in both
MATLAB and this port -- this is a genuine bug in the original MATLAB app,
not something the port introduced, and it explains why real interactive use
of the Corr tab could feel broken (a click on an obvious cell landing on
essentially noise instead). Fixed by reverting to the untransposed value
project-wide: the FOV display, on_pixel_click, mask growth, and overlay
rendering now all share one coordinate system, so no compensating swap is
needed anywhere. Trade-off: the displayed FOV image is a mirror of what
real MATLAB shows on screen for the same file (movie data and every
algorithm's math are unaffected -- only display/click orientation) -- a
deliberate choice, matching a buggy display convention isn't worth
inheriting the interactivity bug it causes. See
algorithms/projections.py's module docstring for the full account, and
tests/gui/test_controller.py::test_clicking_where_a_cell_visually_appears_on_the_displayed_fov_finds_it
for the regression test (deliberately clicks where a cell appears on the
display, not at its known ground-truth location, since every other test
bypasses the display and would not have caught this).
Also discovered (not a port issue, informational)
computeCurrentTraces.m(the standalone file used by both segmentation functions' auto-threshold search loops) hardcodesIMG(...,1:1000). Confirmed by direct MATLAB execution: this crashes app2.m itself ("Index exceeds array bounds") whenever a user auto-segments on a recording with fewer than 1000 frames. This port's equivalent (algorithms/traces.py) uses ordinary Python slicing, which truncates gracefully instead of erroring -- a robustness improvement, not a fidelity gap, since there's no sensible MATLAB behavior to match here.- The
cellnpdecorrauto-threshold method can degenerate to a 1-2 pixel mask on real data with denser neuropil contamination (confirmed mathematically correct per the ported formula, and this is inherent to the objective itself -- maximizing decorrelation from the surround can trivially "win" by shrinking to the single most cell-specific pixel).cellnpdiffproduced a much better-sized mask on the same real test point. Worth knowing if auto-segmentation looks too aggressive in practice -- try the other auto method or a manual threshold.
Still not validated (no real files available for these specifically)
- A real Suite2p
Fall.matand a real legacy (pre-app2)manual_rois.mat.
This validation was originally run through the retired MONKEYDIR/Day/Rec
flow and re-confirmed after the data-loading redesign (Load File/Load
Folder, see "Run" above) by loading the same real TIFF directly through
AppController.load_movie(): the mean projection, a corr-based mask, and a
fluor-based mask all still match the real-MATLAB references above to
floating-point/pixel precision (once accounting for the old flow's
nfr - 50 trailing-frame trim, which the new loader deliberately does not
apply -- see io/movie_loader.py). The segmentation/projection math is
unchanged; only how a movie gets opened changed.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file roiapp-0.1.0.tar.gz.
File metadata
- Download URL: roiapp-0.1.0.tar.gz
- Upload date:
- Size: 114.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae44286961eb3d38482b55a4e8d1b84f551e36667c314d971912e365e1823523
|
|
| MD5 |
7517910410dca8a4a5ce21a69d520cb7
|
|
| BLAKE2b-256 |
627e73bd0f93f860a0863aaf7f66e668b01248a70a5718d6c4f96ee9738b3ab3
|
File details
Details for the file roiapp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: roiapp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 127.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c77eec79054faaccd89088790de0ee2aa2c0b1b2d5f313f61f7ebccdcb85013
|
|
| MD5 |
d12431eb57ae00bd18b0e9f12374e209
|
|
| BLAKE2b-256 |
2b8fae7702e6fcb6364d3f1244cb075707c11d51e5df06036097e9b1b490f837
|