Skip to content

Commit 73e9d4d

Browse files
Merge pull request #1216 from MouseLand/fix_mps_gui
Fix GUI Issues with mac machines and NWB warning issue.
2 parents 6f34bdc + e68a68d commit 73e9d4d

8 files changed

Lines changed: 152 additions & 51 deletions

File tree

suite2p/__main__.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,7 @@ def main():
5151
logging.exception(f'fatal error in {"run_plane" if args.single_plane else "run_s2p"}:')
5252
raise
5353

54-
else:
55-
# Check if the OS is macOS and the machine is Apple Silicon (ARM-based)
56-
if platform.system() == "Darwin" and 'arm' in platform.processor().lower():
57-
# Set the number of threads for OpenMP and OpenBLAS
58-
os.environ["OMP_NUM_THREADS"] = "1"
59-
os.environ["OPENBLAS_NUM_THREADS"] = "1"
60-
print("Environment set to use 1 thread for OpenMP and OpenBLAS (Apple Silicon macOS).")
61-
else:
62-
print("Not macOS on Apple Silicon, proceeding without limiting threads.")
63-
54+
else:
6455
from suite2p import gui
6556
gui.run()#statfile="C:/DATA/exs2p/suite2p/plane0/stat.npy")
6657

suite2p/extraction/extract.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,22 @@ def extract_traces(f_in, cell_masks, neuropil_masks, batch_size=500,
5757
if device.type == 'mps':
5858
device = torch.device('cpu')
5959

60-
npix_neuropil = torch.Tensor([len(nm) for nm in neuropil_masks]).to(device)
61-
# create coo tensor of neuropil and cell masks
62-
ccol_indices = [m for nm in neuropil_masks for m in nm]
63-
row_indices = [k for k in range(len(neuropil_masks)) for m in neuropil_masks[k]]
64-
inds = torch.Tensor([ccol_indices, row_indices]).to(device)
65-
# convert to csc (tried creating csc directly but it was slow)
66-
nmasks = torch.sparse_coo_tensor(inds, torch.ones(len(row_indices), device=device),
67-
size=(Ly*Lx, ncells))
68-
nmasks = nmasks.to_sparse_csc()
60+
if neuropil_masks is not None:
61+
npix_neuropil = torch.Tensor([len(nm) for nm in neuropil_masks]).to(device)
62+
# create coo tensor of neuropil masks
63+
ccol_indices = [m for nm in neuropil_masks for m in nm]
64+
row_indices = [k for k in range(len(neuropil_masks)) for m in neuropil_masks[k]]
65+
inds = torch.Tensor([ccol_indices, row_indices]).to(device)
66+
# convert to csc (tried creating csc directly but it was slow)
67+
nmasks = torch.sparse_coo_tensor(inds, torch.ones(len(row_indices), device=device),
68+
size=(Ly*Lx, ncells))
69+
nmasks = nmasks.to_sparse_csc()
6970

7071
ccol_indices = [m for cm in cell_masks for m in cm[0]]
7172
row_indices = [k for k in range(len(cell_masks)) for m in cell_masks[k][0]]
7273
cell_lam = torch.Tensor([l for cm in cell_masks for l in cm[1]]).to(device)
7374
inds = torch.Tensor([ccol_indices, row_indices]).to(device)
74-
cmasks = torch.sparse_coo_tensor(inds, cell_lam,
75+
cmasks = torch.sparse_coo_tensor(inds, cell_lam,
7576
size=(Ly*Lx, ncells))
7677
cmasks = cmasks.to_sparse_csc()
7778

@@ -87,11 +88,12 @@ def extract_traces(f_in, cell_masks, neuropil_masks, batch_size=500,
8788
tstart, tend = n * batch_size, min((n+1) * batch_size, n_frames)
8889
data = torch.from_numpy(f_in[tstart : tend]).to(device)
8990
data = data.reshape(-1, Ly*Lx).float()
90-
91-
Fneu_batch = (data @ nmasks) / npix_neuropil
92-
Fneu[:, tstart : tend] = Fneu_batch.T.cpu().numpy()
93-
94-
F_batch = data @ cmasks
91+
92+
if neuropil_masks is not None:
93+
Fneu_batch = (data @ nmasks) / npix_neuropil
94+
Fneu[:, tstart : tend] = Fneu_batch.T.cpu().numpy()
95+
96+
F_batch = data @ cmasks
9597
F[:, tstart : tend] = F_batch.T.cpu().numpy()
9698

9799
return F, Fneu

suite2p/gui/menus.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from qtpy import QtGui
55
from qtpy.QtWidgets import QAction, QMenu
6-
from pkg_resources import iter_entry_points
6+
from importlib.metadata import entry_points
77

88
from . import reggui, drawroi, merge, io, rungui, visualize, classgui
99
from suite2p.io.nwb import save_nwb
@@ -166,7 +166,13 @@ def plugins(parent):
166166
main_menu = parent.menuBar()
167167
parent.plugins = {}
168168
plugin_menu = main_menu.addMenu("&Plugins")
169-
for entry_pt in iter_entry_points(group="suite2p.plugin", name=None):
169+
try:
170+
# Works for python 3.12+
171+
suite2p_plugins = entry_points(group="suite2p.plugin")
172+
except TypeError:
173+
# works for Python 3.9-3.11
174+
suite2p_plugins = entry_points().get("suite2p.plugin", [])
175+
for entry_pt in suite2p_plugins:
170176
plugin_obj = entry_pt.load() # load the advertised class from entry_points
171177
parent.plugins[entry_pt.name] = plugin_obj(
172178
parent

suite2p/gui/rungui_utils.py

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,25 +115,67 @@ def setup_logger(name):
115115
return logger
116116

117117

118-
class Suite2pWorker(QtCore.QThread):
118+
class Suite2pWorker(QtCore.QObject):
119+
"""Worker that runs suite2p in a separate process to avoid QThread stack limitations on macOS."""
119120
finished = QtCore.Signal(str)
120-
121+
121122
def __init__(self, parent, db_file, settings_file):
122123
super(Suite2pWorker, self).__init__()
123124
self.db_file = db_file
124125
self.settings_file = settings_file
125126
self.parent = parent
126-
# self.logHandler = ThreadLogger()
127-
128-
def run(self):
129-
db = np.load(self.db_file, allow_pickle=True).item()
130-
settings = np.load(self.settings_file, allow_pickle=True).item()
131-
132-
try:
133-
logger_setup(get_save_folder(db))
134-
run_s2p(db=db, settings=settings)
127+
self.process = None
128+
129+
def start(self):
130+
"""Start suite2p in a separate process using QProcess."""
131+
self.process = QtCore.QProcess()
132+
self.process.setProcessChannelMode(QtCore.QProcess.MergedChannels)
133+
self.process.readyReadStandardOutput.connect(self._on_output)
134+
self.process.finished.connect(self._on_finished)
135+
136+
# Create a Python script to run suite2p
137+
script = f'''
138+
import numpy as np
139+
from suite2p.run_s2p import logger_setup, run_s2p, get_save_folder
140+
141+
db = np.load("{self.db_file}", allow_pickle=True).item()
142+
settings = np.load("{self.settings_file}", allow_pickle=True).item()
143+
144+
logger_setup(get_save_folder(db))
145+
run_s2p(db=db, settings=settings)
146+
'''
147+
self.process.start(sys.executable, ["-c", script])
148+
149+
def _on_output(self):
150+
"""Handle output from the subprocess."""
151+
if self.process:
152+
data = self.process.readAllStandardOutput()
153+
text = bytes(data).decode("utf-8", errors="replace")
154+
print(text, end="")
155+
156+
def _on_finished(self, exit_code, exit_status):
157+
"""Handle process completion."""
158+
if exit_code == 0:
135159
self.finished.emit("finished")
136-
except Exception as e:
137-
print("ERROR:", e)
138-
traceback.print_exc()
139-
self.finished.emit("error")
160+
else:
161+
self.finished.emit("error")
162+
163+
def terminate(self):
164+
"""Terminate the subprocess if running."""
165+
if self.process and self.process.state() != QtCore.QProcess.NotRunning:
166+
self.process.terminate()
167+
168+
def quit(self):
169+
"""Stop the subprocess (alias for terminate, for QThread compatibility)."""
170+
self.terminate()
171+
172+
def wait(self):
173+
"""Wait for the process to finish (for compatibility)."""
174+
if self.process:
175+
self.process.waitForFinished(-1)
176+
177+
def isRunning(self):
178+
"""Check if the process is still running (for QThread compatibility)."""
179+
if self.process:
180+
return self.process.state() == QtCore.QProcess.Running
181+
return False

suite2p/io/nwb.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def _load_npy_cross_platform(path):
5454
NWB = True
5555
except ModuleNotFoundError:
5656
NWB = False
57+
logger.warning("pynwb not installed, save_nwb, read_nwb, and nwb_to_binary "
58+
"will not work. Install with: pip install pynwb")
5759

5860

5961
def nwb_to_binary(settings):

suite2p/registration/nonrigid.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -443,11 +443,19 @@ def transform_data(data, nblocks, xblock, yblock, ymax1, xmax1,
443443
yxup = yxup.permute(0, 2, 3, 1)
444444

445445
if device.type == "mps":
446-
# Manually pad the input tensor with the border values
446+
# Manually pad the input tensor with the border values.
447447
data_padded = F.pad(data.float().unsqueeze(1), (1, 1, 1, 1), mode="replicate")
448-
height, width = data.shape[-2:] # Get the height and width of the original data tensor
449-
# Adjust the grid to account for the padding
450-
adjusted_yxup = yxup + torch.tensor([[[[1 / width, 1 / height]]]]).to(yxup.device) # Adjust grid
448+
# Get the height and width of the original data tensor
449+
height, width = data.shape[-2:]
450+
# Scale the grid to account for the padding. Padded data is now of shape (width + 2) x (height + 2).
451+
# Scale_x and scale_y adjust so we exclude the padding. Align_corner is set to true so original image width is width -1. Same for the height.
452+
scale_x = (width - 1) / (width + 1)
453+
scale_y = (height - 1) / (height + 1)
454+
# Scale the padded image to be within the right coordinates for sampling
455+
adjusted_yxup = yxup * torch.tensor([[[[scale_x, scale_y]]]]).to(yxup.device)
456+
# Clamp the grid before subsampling as all coordinate values must lie between [-1,1].
457+
# Sampling should always be along the image (not include padding coordinates, which will exceed [-1,1] range).
458+
adjusted_yxup = torch.clamp(adjusted_yxup, -1, 1)
451459
# Perform grid sampling on the padded tensor
452460
fr_shift = F.grid_sample(
453461
data_padded,
@@ -460,5 +468,5 @@ def transform_data(data, nblocks, xblock, yblock, ymax1, xmax1,
460468
fr_shift = F.grid_sample(data.float().unsqueeze(1), yxup[:,:,:,[1,0]],
461469
mode="bilinear", padding_mode="border", align_corners=True)
462470

463-
471+
464472
return fr_shift.squeeze().short()#.cpu().numpy()

suite2p/registration/register.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,24 @@ def compute_filters_and_norm(refImg, norm_frames=True, spatial_smooth=1.15, spat
275275
maskMul, maskOffset, cfRefImg = rigid.compute_masks_ref_smooth_fft(refImg=rimg, maskSlope=spatial_taper,
276276
smooth_sigma=spatial_smooth)
277277
Ly, Lx = refImg.shape
278+
# MPS backend does not support float64, convert to float32
279+
if device.type == "mps":
280+
maskMul, maskOffset = maskMul.to(torch.float32), maskOffset.to(torch.float32)
281+
cfRefImg = cfRefImg.to(torch.complex64)
278282
maskMul, maskOffset = maskMul.to(device), maskOffset.to(device)
279283
cfRefImg = cfRefImg.to(device)
280284
blocks = []
281285
if block_size is not None:
282286
blocks = nonrigid.make_blocks(Ly=Ly, Lx=Lx, block_size=block_size,
283287
lpad=lpad, subpixel=subpixel)
284288
maskMulNR, maskOffsetNR, cfRefImgNR = nonrigid.compute_masks_ref_smooth_fft(
285-
refImg0=rimg, maskSlope=spatial_taper, smooth_sigma=spatial_smooth,
289+
refImg0=rimg, maskSlope=spatial_taper, smooth_sigma=spatial_smooth,
286290
yblock=blocks[0], xblock=blocks[1],
287291
)
292+
# MPS backend does not support float64, convert to float32
293+
if device.type == "mps":
294+
maskMulNR, maskOffsetNR = maskMulNR.to(torch.float32), maskOffsetNR.to(torch.float32)
295+
cfRefImgNR = cfRefImgNR.to(torch.complex64)
288296
maskMulNR, maskOffsetNR = maskMulNR.to(device), maskOffsetNR.to(device)
289297
cfRefImgNR = cfRefImgNR.to(device)
290298

@@ -440,6 +448,10 @@ def shift_frames(fr_torch, yoff, xoff, yoff1=None, xoff1=None, blocks=None,
440448
if fr_torch.device.type == "cuda":
441449
yoff1 = torch.from_numpy(yoff1).pin_memory().to(device)
442450
xoff1 = torch.from_numpy(xoff1).pin_memory().to(device)
451+
elif device.type == "mps":
452+
# MPS backend does not support float64
453+
yoff1 = torch.from_numpy(yoff1).to(torch.float32).to(device)
454+
xoff1 = torch.from_numpy(xoff1).to(torch.float32).to(device)
443455
else:
444456
yoff1 = torch.from_numpy(yoff1).to(device)
445457
xoff1 = torch.from_numpy(xoff1).to(device)
@@ -580,7 +592,9 @@ def register_frames(f_align_in, refImg, f_align_out=None, batch_size=100,
580592
if upsample_meanImg:
581593
if not isinstance(upsample_meanImg, (np.ndarray, list, tuple)):
582594
upsample_meanImg = [upsample_meanImg, upsample_meanImg]
583-
mean_img_ups = torch.zeros((int(Ly*upsample_meanImg[0]), int(Lx*upsample_meanImg[1])), dtype=torch.double, device=device)
595+
# MPS backend does not support float64
596+
ups_dtype = torch.float32 if device.type == "mps" else torch.double
597+
mean_img_ups = torch.zeros((int(Ly*upsample_meanImg[0]), int(Lx*upsample_meanImg[1])), dtype=ups_dtype, device=device)
584598
counts_ups = torch.zeros((int(Ly*upsample_meanImg[0]), int(Lx*upsample_meanImg[1])), dtype=torch.int, device=device)
585599
else:
586600
mean_img_ups, counts_ups, meanImg_ups = None, None, None
@@ -890,7 +904,10 @@ def registration_wrapper(f_reg, f_raw=None, f_reg_chan2=None, f_raw_chan2=None,
890904

891905
nchannels = 2 if f_alt_in is not None else 1
892906
logger.info(f"registering {nchannels} channels")
893-
907+
if device.type == "mps":
908+
logger.warning("MPS device does not support float64, using float32 for registration. "
909+
"If you encounter registration issues, try using cuda or cpu instead.")
910+
894911
### ----- compute reference image and bidiphase shift -------------- ###
895912
n_frames, Ly, Lx = f_align_in.shape
896913
badframes0 = np.zeros(n_frames, "bool") if badframes is None else badframes.copy()

tests/test_registration.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import numpy as np
2+
import pytest
3+
import torch
24
from suite2p.registration import bidiphase
5+
from suite2p.registration.nonrigid import transform_data
36

47

58
def test_positive_bidiphase_shift_shifts_every_other_line():
@@ -41,4 +44,34 @@ def test_negative_bidiphase_shift_shifts_every_other_line():
4144

4245
shifted = orig.copy()
4346
bidiphase.shift(shifted, -2)
44-
assert np.allclose(shifted, expected)
47+
assert np.allclose(shifted, expected)
48+
49+
50+
@pytest.mark.skipif(not torch.backends.mps.is_available(), reason="MPS not available")
51+
def test_transform_data_mps_cpu_consistency():
52+
"""Test that MPS and CPU code paths in transform_data produce similar results."""
53+
from suite2p.registration.nonrigid import make_blocks
54+
55+
np.random.seed(42)
56+
torch.manual_seed(42)
57+
Ly, Lx, n_frames = 128, 128, 2
58+
yblock, xblock, nblocks, *_ = make_blocks(Ly, Lx, (32, 32))
59+
data_np = np.random.rand(n_frames, Ly, Lx).astype(np.float32) * 100
60+
ymax1 = torch.randn(nblocks[0] * nblocks[1], n_frames) * 2
61+
xmax1 = torch.randn(nblocks[0] * nblocks[1], n_frames) * 2
62+
63+
result_cpu = transform_data(
64+
torch.from_numpy(data_np), nblocks, xblock, yblock, ymax1.clone(), xmax1.clone()
65+
)
66+
result_mps = transform_data(
67+
torch.from_numpy(data_np).to("mps"), nblocks, xblock, yblock,
68+
ymax1.clone().to("mps"), xmax1.clone().to("mps")
69+
)
70+
71+
cpu_np = result_cpu.numpy().astype(np.float32)
72+
mps_np = result_mps.cpu().numpy().astype(np.float32)
73+
correlation = np.corrcoef(cpu_np.flatten(), mps_np.flatten())[0, 1]
74+
max_diff = np.abs(cpu_np - mps_np).max()
75+
76+
assert correlation > 0.99, f"Correlation: {correlation}"
77+
assert max_diff < 2, f"Max diff: {max_diff}"

0 commit comments

Comments
 (0)