Skip to content

Commit 193e7f1

Browse files
authored
Merge pull request #1069 from yael-prilutski/feature/raw2bin
Added support for Thorlabs RAW files (io.raw)
2 parents 1a2763f + b042a22 commit 193e7f1

File tree

6 files changed

+318
-17
lines changed

6 files changed

+318
-17
lines changed

docs/inputs.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,10 @@ you're using this and having trouble because it's not straightforward.
129129
Thorlabs raw files
130130
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
131131

132-
Christoph Schmidt-Hieber (@neurodroid) has written `haussmeister`_ which
133-
can load and convert ThorLabs \*.raw files to suite2p binary files!
134-
suite2p will automatically use this if you have pip installed it
135-
(``pip install haussmeister``).
132+
Suite2p has been upgraded with internal support for Thorlabs raw files (Yael Prilutski).
133+
Specify "raw" for "input_format".
134+
Designed to work with one or several planes and/or channels.
135+
136136

137137
.. _hdf5-files-and-sbx:
138138

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"nd2",
2727
"sbxreader",
2828
"h5py",
29-
"opencv-python-headless"
29+
"opencv-python-headless",
30+
"xmltodict"
3031
]
3132

3233
nwb_deps = [

suite2p/gui/rungui.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def create_buttons(self):
276276
self.inputformat = QComboBox()
277277
[
278278
self.inputformat.addItem(f)
279-
for f in ["tif", "binary", "bruker", "sbx", "h5", "movie", "nd2", "mesoscan", "haus"]
279+
for f in ["tif", "binary", "bruker", "sbx", "h5", "movie", "nd2", "mesoscan", "raw"]
280280
]
281281
self.inputformat.currentTextChanged.connect(self.parse_inputformat)
282282
self.layout.addWidget(self.inputformat, 2, 0, 1, 1)

suite2p/io/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Copyright © 2023 Howard Hughes Medical Institute, Authored by Carsen Stringer and Marius Pachitariu.
33
"""
44
from .h5 import h5py_to_binary
5+
from .raw import raw_to_binary
56
from .nwb import save_nwb, read_nwb, nwb_to_binary
67
from .save import combined, compute_dydx, save_mat
78
from .sbx import sbx_to_binary

suite2p/io/raw.py

+308
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
"""
2+
Copyright © 2023 Yoav Livneh Lab, Authored by Yael Prilutski.
3+
"""
4+
5+
import numpy as np
6+
7+
from os import makedirs, listdir
8+
from os.path import isdir, isfile, getsize, join
9+
10+
try:
11+
from xmltodict import parse
12+
HAS_XML = True
13+
except (ModuleNotFoundError, ImportError):
14+
HAS_XML = False
15+
16+
EXTENSION = 'raw'
17+
18+
19+
def raw_to_binary(ops, use_recorded_defaults=True):
20+
21+
""" Finds RAW files and writes them to binaries
22+
23+
Parameters
24+
----------
25+
ops : dictionary
26+
"data_path"
27+
28+
use_recorded_defaults : bool
29+
Recorded session parameters are used when 'True',
30+
otherwise |ops| is expected to contain the following (additional) keys:
31+
"nplanes",
32+
"nchannels",
33+
"fs"
34+
35+
Returns
36+
-------
37+
ops : dictionary of first plane
38+
39+
"""
40+
41+
if not HAS_XML:
42+
raise ImportError("xmltodict is required for RAW file support (pip install xmltodict)")
43+
44+
# Load raw file configurations
45+
raw_file_configurations = [_RawFile(path) for path in ops['data_path']]
46+
47+
# Split ops by captured planes
48+
ops_paths = _initialize_destination_files(ops, raw_file_configurations, use_recorded_defaults=use_recorded_defaults)
49+
50+
# Convert all runs in order
51+
for path in ops['data_path']:
52+
print(f'Converting raw to binary: `{path}`')
53+
ops_loaded = [np.load(i, allow_pickle=True)[()] for i in ops_paths]
54+
_raw2bin(ops_loaded, _RawFile(path))
55+
56+
# Reload edited ops
57+
ops_loaded = [np.load(i, allow_pickle=True)[()] for i in ops_paths]
58+
59+
# Create a mean image with the final number of frames
60+
_update_mean(ops_loaded)
61+
62+
# Load & return all ops
63+
return ops_loaded[0]
64+
65+
66+
def _initialize_destination_files(ops, raw_file_configurations, use_recorded_defaults=True):
67+
68+
""" Prepares raw2bin conversion environment (files & folders) """
69+
70+
configurations = [
71+
[cfg.channel, cfg.zplanes, cfg.xpx, cfg.ypx, cfg.frame_rate, cfg.xsize, cfg.ysize]
72+
for cfg in raw_file_configurations
73+
]
74+
75+
# Make sure all ops match each other
76+
assert all(conf == configurations[0] for conf in configurations), \
77+
f'Data attributes do not match. Can not concatenate shapes: {[conf for conf in configurations]}'
78+
79+
# Load configuration from first file in paths
80+
cfg = raw_file_configurations[0]
81+
82+
# Expand configuration from defaults when necessary
83+
if use_recorded_defaults:
84+
ops['nplanes'] = cfg.zplanes
85+
if cfg.channel > 1:
86+
ops['nchannels'] = 2
87+
ops['fs'] = cfg.frame_rate
88+
89+
# Prepare conversion environment for all files
90+
ops_paths = []
91+
nplanes = ops['nplanes']
92+
nchannels = ops['nchannels']
93+
second_plane = False
94+
for i in range(0, nplanes):
95+
ops['save_path'] = join(ops['save_path0'], 'suite2p', f'plane{i}')
96+
97+
if ('fast_disk' not in ops) or len(ops['fast_disk']) == 0 or second_plane:
98+
ops['fast_disk'] = ops['save_path']
99+
second_plane = True
100+
else:
101+
ops['fast_disk'] = join(ops['fast_disk'], 'suite2p', f'plane{i}')
102+
103+
ops['ops_path'] = join(ops['save_path'], 'ops.npy')
104+
ops['reg_file'] = join(ops['fast_disk'], 'data.bin')
105+
isdir(ops['fast_disk']) or makedirs(ops['fast_disk'])
106+
isdir(ops['save_path']) or makedirs(ops['save_path'])
107+
open(ops['reg_file'], 'wb').close()
108+
if nchannels > 1:
109+
ops['reg_file_chan2'] = join(ops['fast_disk'], 'data_chan2.bin')
110+
open(ops['reg_file_chan2'], 'wb').close()
111+
112+
ops['meanImg'] = np.zeros((cfg.xpx, cfg.ypx), np.float32)
113+
ops['nframes'] = 0
114+
ops['frames_per_run'] = []
115+
if nchannels > 1:
116+
ops['meanImg_chan2'] = np.zeros((cfg.xpx, cfg.ypx), np.float32)
117+
118+
# write ops files
119+
do_registration = ops['do_registration']
120+
ops['Ly'] = cfg.xpx
121+
ops['Lx'] = cfg.ypx
122+
if not do_registration:
123+
ops['yrange'] = np.array([0, ops['Ly']])
124+
ops['xrange'] = np.array([0, ops['Lx']])
125+
126+
ops_paths.append(ops['ops_path'])
127+
np.save(ops['ops_path'], ops)
128+
129+
# Environment ready;
130+
return ops_paths
131+
132+
133+
def _raw2bin(all_ops, cfg):
134+
135+
""" Converts a single RAW file to BIN format """
136+
137+
frames_in_chunk = int(all_ops[0]['batch_size'])
138+
139+
with open(cfg.path, 'rb') as raw_file:
140+
chunk = frames_in_chunk * cfg.xpx * cfg.ypx * cfg.channel * cfg.recorded_planes * 2
141+
raw_data_chunk = raw_file.read(chunk)
142+
while raw_data_chunk:
143+
data = np.frombuffer(raw_data_chunk, dtype=np.int16)
144+
current_frames = int(len(data) / cfg.xpx / cfg.ypx / cfg.recorded_planes)
145+
146+
if cfg.channel > 1:
147+
channel_a, channel_b = _split_into_2_channels(data.reshape(
148+
current_frames * cfg.recorded_planes, cfg.xpx, cfg.ypx))
149+
reshaped_data = []
150+
for i in range(cfg.recorded_planes):
151+
channel_a_plane = channel_a[i::cfg.recorded_planes]
152+
channel_b_plane = channel_b[i::cfg.recorded_planes]
153+
reshaped_data.append([channel_a_plane, channel_b_plane])
154+
155+
else:
156+
reshaped_data = data.reshape(cfg.recorded_planes, current_frames, cfg.xpx, cfg.ypx)
157+
158+
for plane in range(0, cfg.zplanes):
159+
ops = all_ops[plane]
160+
plane_data = reshaped_data[plane]
161+
162+
if cfg.channel > 1:
163+
with open(ops['reg_file'], 'ab') as bin_file:
164+
bin_file.write(bytearray(plane_data[0].astype(np.int16)))
165+
with open(ops['reg_file_chan2'], 'ab') as bin_file2:
166+
bin_file2.write(bytearray(plane_data[1].astype(np.int16)))
167+
ops['meanImg'] += plane_data[0].astype(np.float32).sum(axis=0)
168+
ops['meanImg_chan2'] = ops['meanImg_chan2'] + plane_data[1].astype(np.float32).sum(axis=0)
169+
170+
else:
171+
with open(ops['reg_file'], 'ab') as bin_file:
172+
bin_file.write(bytearray(plane_data.astype(np.int16)))
173+
ops['meanImg'] = ops['meanImg'] + plane_data.astype(np.float32).sum(axis=0)
174+
175+
raw_data_chunk = raw_file.read(chunk)
176+
177+
for ops in all_ops:
178+
total_frames = int(cfg.size / cfg.xpx / cfg.ypx / cfg.recorded_planes / cfg.channel / 2)
179+
ops['frames_per_run'].append(total_frames)
180+
ops['nframes'] += total_frames
181+
np.save(ops['ops_path'], ops)
182+
183+
184+
def _split_into_2_channels(data):
185+
186+
""" Utility function, used during conversion - splits given raw data into 2 separate channels """
187+
188+
frames = data.shape[0]
189+
channel_a_index = list(filter(lambda x: x % 2 == 0, range(frames)))
190+
channel_b_index = list(filter(lambda x: x % 2 != 0, range(frames)))
191+
return data[channel_a_index], data[channel_b_index]
192+
193+
194+
def _update_mean(ops_loaded):
195+
196+
""" Adjusts all "meanImg" values at the end of raw-to-binary conversion. """
197+
198+
for ops in ops_loaded:
199+
ops['meanImg'] /= ops['nframes']
200+
np.save(ops['ops_path'], ops)
201+
202+
203+
class _RawConfig:
204+
205+
""" Handles XML configuration parsing and exposes video shape & parameters for Thorlabs RAW files """
206+
207+
def __init__(self, raw_file_size, xml_path):
208+
209+
assert isfile(xml_path)
210+
211+
self._xml_path = xml_path
212+
213+
self.zplanes = 1
214+
self.recorded_planes = 1
215+
216+
self.xpx = None
217+
self.ypx = None
218+
self.channel = None
219+
self.frame_rate = None
220+
self.xsize = None
221+
self.ysize = None
222+
self.nframes = None
223+
224+
# Load configuration defaults
225+
with open(self._xml_path, 'r', encoding='utf-8') as file:
226+
self._load_xml_config(raw_file_size, parse(file.read()))
227+
228+
# Make sure all fields have been filled
229+
assert None not in (self.xpx, self.ypx, self.channel, self.frame_rate, self.xsize, self.ysize, self.nframes)
230+
231+
# Extract data shape
232+
self._shape = self._find_shape()
233+
234+
@property
235+
def shape(self): return self._shape
236+
237+
def _find_shape(self):
238+
239+
""" Discovers data dimensions """
240+
241+
shape = [self.nframes, self.xpx, self.ypx]
242+
if self.recorded_planes > 1:
243+
shape.insert(0, self.recorded_planes)
244+
if self.channel > 1:
245+
shape[0] = self.nframes * 2
246+
return shape
247+
248+
def _load_xml_config(self, raw_file_size, xml):
249+
250+
""" Loads recording parameters from attached XML;
251+
252+
:param raw_file_size: Size (in bytes) of main RAW file
253+
:param xml: Original XML contents as created during data acquisition (pre-parsed to a python dictionary) """
254+
255+
xml_data = xml['ThorImageExperiment']
256+
257+
self.xpx = int(xml_data['LSM']['@pixelX'])
258+
self.ypx = int(xml_data['LSM']['@pixelY'])
259+
self.channel = int(xml_data['LSM']['@channel'])
260+
self.frame_rate = float(xml_data['LSM']['@frameRate'])
261+
self.xsize = float(xml_data['LSM']['@widthUM'])
262+
self.ysize = float(xml_data['LSM']['@heightUM'])
263+
self.nframes = int(xml_data['Streaming']['@frames'])
264+
265+
flyback = int(xml_data['Streaming']['@flybackFrames'])
266+
zenable = int(xml_data['Streaming']['@zFastEnable'])
267+
planes = int(xml_data['ZStage']['@steps'])
268+
269+
if self.channel > 1:
270+
self.channel = 2
271+
272+
if zenable > 0:
273+
self.zplanes = planes
274+
self.recorded_planes = flyback + self.zplanes
275+
self.nframes = int(self.nframes / self.recorded_planes)
276+
277+
if xml_data['ExperimentStatus']['@value'] == 'Stopped':
278+
# Recording stopped in the middle, the written frame number isn't correct
279+
all_frames = int(raw_file_size / self.xpx / self.ypx / self.recorded_planes / self.channel / 2)
280+
self.nframes = int(all_frames / self.recorded_planes)
281+
282+
283+
class _RawFile(_RawConfig):
284+
285+
""" These objects represents all recording parameters per single Thorlabs RAW file """
286+
287+
_MAIN_FILE_SUFFIX = f'001.{EXTENSION}'
288+
289+
def __init__(self, dir_name):
290+
self._dirname = dir_name
291+
filenames = listdir(dir_name)
292+
293+
# Find main raw file
294+
main_files = [fn for fn in filenames if fn.lower().endswith(self._MAIN_FILE_SUFFIX)]
295+
assert 1 == len(main_files), f'Corrupted directory structure: "{dir_name}"'
296+
self._raw_file_path = join(dir_name, main_files[0])
297+
self._raw_file_size = getsize(self._raw_file_path)
298+
299+
# Load XML config
300+
xml_files = [fn for fn in filenames if fn.lower().endswith('.xml')]
301+
assert 1 == len(xml_files), f'Missing required XML configuration file from dir="{dir_name}"'
302+
_RawConfig.__init__(self, self._raw_file_size, join(dir_name, xml_files[0]))
303+
304+
@property
305+
def path(self): return self._raw_file_path
306+
307+
@property
308+
def size(self): return self._raw_file_size

suite2p/run_s2p.py

+2-11
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,6 @@
1414

1515
from . import extraction, io, registration, detection, classification, default_ops
1616

17-
try:
18-
from haussmeister import haussio
19-
HAS_HAUS = True
20-
except ImportError:
21-
HAS_HAUS = False
22-
2317
try:
2418
import pynwb
2519
HAS_NWB = True
@@ -466,8 +460,6 @@ def run_s2p(ops={}, db={}, server={}):
466460
ops["input_format"] = "nd2"
467461
if not HAS_ND2:
468462
raise ImportError("nd2 not found; pip install nd2")
469-
elif HAS_HAUS:
470-
ops["input_format"] = "haus"
471463
elif not "input_format" in ops:
472464
ops["input_format"] = "tif"
473465
elif ops["input_format"] == "movie":
@@ -486,9 +478,8 @@ def run_s2p(ops={}, db={}, server={}):
486478
io.nd2_to_binary,
487479
"mesoscan":
488480
io.mesoscan_to_binary,
489-
"haus":
490-
lambda ops: haussio.load_haussio(ops["data_path"][0]).tosuite2p(ops.
491-
copy()),
481+
"raw":
482+
io.raw_to_binary,
492483
"bruker":
493484
io.ome_to_binary,
494485
"movie":

0 commit comments

Comments
 (0)