Coverage for ibllib/pipes/mesoscope_tasks.py: 82%
520 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
1"""The mesoscope data extraction pipeline.
3The mesoscope pipeline currently comprises raw image registration and timestamps extraction. In
4the future there will be compression (and potential cropping), FOV metadata extraction, and ROI
5extraction.
7Pipeline:
8 1. Register reference images and upload snapshots and notes to Alyx
9 2. Run ROI cell detection
10 3. Calculate the pixel and ROI brain locations and register fields of view to Alyx
11 4. Compress the raw imaging data
12 5. Extract the imaging times from the main DAQ
13"""
14import json
15import logging
16import subprocess
17import shutil
18from pathlib import Path
19from itertools import chain
20from collections import defaultdict, Counter
21from fnmatch import fnmatch
22import enum
23import re
24import time
26import numba as nb
27import numpy as np
28import pandas as pd
29import sparse
30from scipy.io import loadmat
31from scipy.interpolate import interpn
32import one.alf.io as alfio
33from one.alf.spec import is_valid, to_alf
34from one.alf.files import filename_parts, session_path_parts
35import one.alf.exceptions as alferr
37from ibllib.pipes import base_tasks
38from ibllib.io.extractors import mesoscope
39from iblatlas.atlas import ALLEN_CCF_LANDMARKS_MLAPDV_UM, MRITorontoAtlas
42_logger = logging.getLogger(__name__)
43Provenance = enum.Enum('Provenance', ['ESTIMATE', 'FUNCTIONAL', 'LANDMARK', 'HISTOLOGY']) # py3.11 make StrEnum
46class MesoscopeRegisterSnapshots(base_tasks.MesoscopeTask, base_tasks.RegisterRawDataTask):
47 """Upload snapshots as Alyx notes and register the 2P reference image(s)."""
48 priority = 100
49 job_size = 'small'
51 @property
52 def signature(self):
53 signature = { 1ol
54 'input_files': [('referenceImage.raw.tif', f'{self.device_collection}/reference', False),
55 ('referenceImage.stack.tif', f'{self.device_collection}/reference', False),
56 ('referenceImage.meta.json', f'{self.device_collection}/reference', False)],
57 'output_files': [('referenceImage.raw.tif', f'{self.device_collection}/reference', False),
58 ('referenceImage.stack.tif', f'{self.device_collection}/reference', False),
59 ('referenceImage.meta.json', f'{self.device_collection}/reference', False)]
60 }
61 return signature 1ol
63 def __init__(self, session_path, **kwargs):
64 super().__init__(session_path, **kwargs) 1polq
65 self.device_collection = self.get_device_collection('mesoscope', 1polq
66 kwargs.get('device_collection', 'raw_imaging_data_*'))
68 def _run(self):
69 """
70 Assert one reference image per collection and rename it. Register snapshots.
72 Returns
73 -------
74 list of pathlib.Path containing renamed reference image.
75 """
76 # Assert that only one tif file exists per collection
77 file, collection, _ = self.signature['input_files'][0] 1l
78 reference_images = list(self.session_path.rglob(f'{collection}/{file}')) 1l
79 assert len(set(x.parent for x in reference_images)) == len(reference_images) 1l
80 # Rename the reference images
81 out_files = super()._run() 1l
82 # Register snapshots in base session folder and raw_imaging_data folders
83 self.register_snapshots(collection=[self.device_collection, '']) 1l
84 return out_files 1l
87class MesoscopeCompress(base_tasks.MesoscopeTask):
88 """ Tar compress raw 2p tif files, optionally remove uncompressed data."""
90 priority = 90
91 job_size = 'large'
92 _log_level = None
94 @property
95 def signature(self):
96 signature = { 1e
97 'input_files': [('*.tif', self.device_collection, True)],
98 'output_files': [('imaging.frames.tar.bz2', self.device_collection, True)]
99 }
100 return signature 1e
102 def setUp(self, **kwargs):
103 """Run at higher log level"""
104 self._log_level = _logger.level 1e
105 _logger.setLevel(logging.DEBUG) 1e
106 return super().setUp(**kwargs) 1e
108 def tearDown(self):
109 _logger.setLevel(self._log_level or logging.INFO) 1e
110 return super().tearDown() 1e
112 def _run(self, remove_uncompressed=False, verify_output=True, clobber=False, **kwargs):
113 """
114 Run tar compression on all tif files in the device collection.
116 Parameters
117 ----------
118 remove_uncompressed: bool
119 Whether to remove the original, uncompressed data. Default is False.
120 verify_output: bool
121 Whether to check that the compressed tar file can be uncompressed without errors.
122 Default is True.
124 Returns
125 -------
126 list of pathlib.Path
127 Path to compressed tar file.
128 """
129 outfiles = [] # should be one per raw_imaging_data folder 1e
130 input_files = defaultdict(list) 1e
131 for file, in_dir, _ in self.input_files: 1e
132 input_files[self.session_path.joinpath(in_dir)].append(file) 1e
134 for in_dir, files in input_files.items(): 1e
135 outfile = in_dir / self.output_files[0][0] 1e
136 if outfile.exists() and not clobber: 1e
137 _logger.info('%s already exists; skipping...', outfile.relative_to(self.session_path))
138 continue
139 # glob for all input patterns
140 infiles = list(chain(*map(lambda x: in_dir.glob(x), files))) 1e
141 if not infiles: 1e
142 _logger.info('No image files found in %s', in_dir.relative_to(self.session_path))
143 continue
145 _logger.debug( 1e
146 'Input files:\n\t%s', '\n\t'.join(map(Path.as_posix, (x.relative_to(self.session_path) for x in infiles)))
147 )
149 uncompressed_size = sum(x.stat().st_size for x in infiles) 1e
150 _logger.info('Compressing %i file(s)', len(infiles)) 1e
151 cmd = 'tar -cjvf "{output}" "{input}"'.format( 1e
152 output=outfile.relative_to(in_dir), input='" "'.join(str(x.relative_to(in_dir)) for x in infiles))
153 _logger.debug(cmd) 1e
154 process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir) 1e
155 info, error = process.communicate() # b'2023-02-17_2_test_2P_00001_00001.tif\n' 1e
156 _logger.debug(info.decode()) 1e
157 assert process.returncode == 0, f'compression failed: {error.decode()}' 1e
159 # Check the output
160 assert outfile.exists(), 'output file missing' 1e
161 outfiles.append(outfile) 1e
162 compressed_size = outfile.stat().st_size 1e
163 min_size = kwargs.pop('verify_min_size', 1024) 1e
164 assert compressed_size > int(min_size), f'Compressed file < {min_size / 1024:.0f}KB' 1e
165 _logger.info('Compression ratio = %.3f, saving %.2f pct (%.2f MB)', 1e
166 uncompressed_size / compressed_size,
167 round((1 - (compressed_size / uncompressed_size)) * 10000) / 100,
168 (uncompressed_size - compressed_size) / 1024 / 1024)
170 if verify_output: 1e
171 # Test bzip
172 cmd = f'bzip2 -tv {outfile.relative_to(in_dir)}' 1e
173 process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir) 1e
174 info, error = process.communicate() 1e
175 _logger.debug(info.decode()) 1e
176 assert process.returncode == 0, f'bzip compression test failed: {error}' 1e
177 # Check tar
178 cmd = f'bunzip2 -dc {outfile.relative_to(in_dir)} | tar -tvf -' 1e
179 process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir) 1e
180 info, error = process.communicate() 1e
181 _logger.debug(info.decode()) 1e
182 assert process.returncode == 0, 'tarball decompression test failed' 1e
183 compressed_files = set(x.split()[-1] for x in filter(None, info.decode().split('\n'))) 1e
184 assert compressed_files == set(x.name for x in infiles) 1e
186 if remove_uncompressed: 1e
187 _logger.info(f'Removing input files for {in_dir.relative_to(self.session_path)}') 1e
188 for file in infiles: 1e
189 file.unlink() 1e
191 return outfiles 1e
194class MesoscopePreprocess(base_tasks.MesoscopeTask):
195 """Run suite2p preprocessing on tif files"""
197 priority = 80
198 cpu = -1
199 job_size = 'large'
201 @property
202 def signature(self):
203 # The number of in and outputs will be dependent on the number of input raw imaging folders and output FOVs
204 signature = { 1f
205 'input_files': [('_ibl_rawImagingData.meta.json', self.device_collection, True),
206 ('*.tif', self.device_collection, True),
207 ('exptQC.mat', self.device_collection, False)],
208 'output_files': [('mpci.ROIActivityF.npy', 'alf/FOV*', True),
209 ('mpci.ROINeuropilActivityF.npy', 'alf/FOV*', True),
210 ('mpci.ROIActivityDeconvolved.npy', 'alf/FOV*', True),
211 ('mpci.badFrames.npy', 'alf/FOV*', True),
212 ('mpci.mpciFrameQC.npy', 'alf/FOV*', True),
213 ('mpciFrameQC.names.tsv', 'alf/FOV*', True),
214 ('mpciMeanImage.images.npy', 'alf/FOV*', True),
215 ('mpciROIs.stackPos.npy', 'alf/FOV*', True),
216 ('mpciROIs.mpciROITypes.npy', 'alf/FOV*', True),
217 ('mpciROIs.cellClassifier.npy', 'alf/FOV*', True),
218 ('mpciROITypes.names.tsv', 'alf/FOV*', True),
219 ('mpciROIs.masks.npy', 'alf/FOV*', True),
220 ('mpciROIs.neuropilMasks.npy', 'alf/FOV*', True),
221 ('_suite2p_ROIData.raw.zip', self.device_collection, False)]
222 }
223 return signature 1f
225 @staticmethod
226 def _masks2sparse(stat, ops):
227 """
228 Extract 3D sparse mask arrays from suit2p output.
230 Parameters
231 ----------
232 stat : numpy.array
233 The loaded stat.npy file. A structured array with fields ('lam', 'ypix', 'xpix', 'neuropil_mask').
234 ops : numpy.array
235 The loaded ops.npy file. A structured array with fields ('Ly', 'Lx').
237 Returns
238 -------
239 sparse.GCXS
240 A pydata sparse array of type float32, representing the ROI masks.
241 sparse.GCXS
242 A pydata sparse array of type float32, representing the neuropil ROI masks.
244 Notes
245 -----
246 Save using sparse.save_npz.
247 """
248 shape = (stat.shape[0], ops['Ly'], ops['Lx']) 1dc
249 npx = np.prod(shape[1:]) # Number of pixels per time point 1dc
250 coords = [[], [], []] 1dc
251 data = [] 1dc
252 pil_coords = [] 1dc
253 for i, s in enumerate(stat): 1dc
254 coords[0].append(np.full(s['ypix'].shape, i)) 1dc
255 coords[1].append(s['ypix']) 1dc
256 coords[2].append(s['xpix']) 1dc
257 data.append(s['lam']) 1dc
258 pil_coords.append(s['neuropil_mask'] + i * npx) 1dc
259 roi_mask_sp = sparse.COO(list(map(np.concatenate, coords)), np.concatenate(data), shape=shape) 1dc
260 pil_mask_sp = sparse.COO(np.unravel_index(np.concatenate(pil_coords), shape), True, shape=shape) 1dc
261 return sparse.GCXS.from_coo(roi_mask_sp), sparse.GCXS.from_coo(pil_mask_sp) 1dc
263 def _rename_outputs(self, suite2p_dir, frameQC_names, frameQC, rename_dict=None):
264 """
265 Convert suite2p output files to ALF datasets.
267 Parameters
268 ----------
269 suite2p_dir : pathlib.Path
270 rename_dict : dict or None
271 The suite2p output filenames and the corresponding ALF name. NB: These files are saved
272 after transposition. Default is None, i.e. using the default mapping hardcoded in the function below.
274 Returns
275 -------
276 list of pathlib.Path
277 All paths found in FOV folders.
278 """
279 if rename_dict is None: 1dc
280 rename_dict = { 1dc
281 'F.npy': 'mpci.ROIActivityF.npy',
282 'spks.npy': 'mpci.ROIActivityDeconvolved.npy',
283 'Fneu.npy': 'mpci.ROINeuropilActivityF.npy'
284 }
285 # Rename the outputs, first the subdirectories
286 for plane_dir in suite2p_dir.iterdir(): 1dc
287 # ignore the combined dir
288 if plane_dir.name != 'combined': 1dc
289 n = int(plane_dir.name.split('plane')[1]) 1dc
290 fov_dir = plane_dir.parent.joinpath(f'FOV_{n:02}') 1dc
291 if fov_dir.exists(): 1dc
292 shutil.rmtree(str(fov_dir), ignore_errors=False, onerror=None)
293 plane_dir.rename(fov_dir) 1dc
294 # Now rename the content of the new directories and move them out of suite2p
295 for fov_dir in suite2p_dir.iterdir(): 1dc
296 # Compress suite2p output files
297 target = suite2p_dir.parent.joinpath(fov_dir.name) 1dc
298 target.mkdir(exist_ok=True) 1dc
299 # Move bin file out of the way first
300 if fov_dir.joinpath('data.bin').exists(): 1dc
301 dst = self.session_path.joinpath('raw_bin_files', fov_dir.name, 'data.bin')
302 dst.parent.mkdir(parents=True, exist_ok=True)
303 _logger.debug('Moving bin file to %s', dst.relative_to(self.session_path))
304 fov_dir.joinpath('data.bin').replace(dst)
305 # Set logger to warning for the moment to not clutter the logs
306 prev_level = _logger.level 1dc
307 _logger.setLevel(logging.WARNING) 1dc
308 shutil.make_archive(str(target / '_suite2p_ROIData.raw'), 'zip', fov_dir, logger=_logger) 1dc
309 _logger.setLevel(prev_level) 1dc
310 if fov_dir != 'combined': 1dc
311 # save frameQC in each dir (for now, maybe there will be fov specific frame QC eventually)
312 if frameQC is not None and len(frameQC) > 0: 1dc
313 np.save(fov_dir.joinpath('mpci.mpciFrameQC.npy'), frameQC) 1c
314 frameQC_names.to_csv(fov_dir.joinpath('mpciFrameQC.names.tsv'), sep='\t', index=False) 1c
316 # extract some other data from suite2p outputs
317 ops = np.load(fov_dir.joinpath('ops.npy'), allow_pickle=True).item() 1dc
318 stat = np.load(fov_dir.joinpath('stat.npy'), allow_pickle=True) 1dc
319 iscell = np.load(fov_dir.joinpath('iscell.npy')) 1dc
321 # Save suite2p ROI activity outputs in transposed from (n_frames, n_ROI)
322 for k, v in rename_dict.items(): 1dc
323 np.save(fov_dir.joinpath(v), np.load(fov_dir.joinpath(k)).T) 1dc
324 # fov_dir.joinpath(k).unlink() # Keep original files for suite2P GUI
325 np.save(fov_dir.joinpath('mpci.badFrames.npy'), np.asarray(ops['badframes'], dtype=bool)) 1dc
326 np.save(fov_dir.joinpath('mpciMeanImage.images.npy'), np.asarray(ops['meanImg'], dtype=float)) 1dc
327 np.save(fov_dir.joinpath('mpciROIs.stackPos.npy'), np.asarray([(*s['med'], 0) for s in stat], dtype=int)) 1dc
328 np.save(fov_dir.joinpath('mpciROIs.cellClassifier.npy'), np.asarray(iscell[:, 1], dtype=float)) 1dc
329 np.save(fov_dir.joinpath('mpciROIs.mpciROITypes.npy'), np.asarray(iscell[:, 0], dtype=np.int16)) 1dc
330 pd.DataFrame([(0, 'no cell'), (1, 'cell')], columns=['roi_values', 'roi_labels'] 1dc
331 ).to_csv(fov_dir.joinpath('mpciROITypes.names.tsv'), sep='\t', index=False)
332 # ROI and neuropil masks
333 roi_mask, pil_mask = self._masks2sparse(stat, ops) 1dc
334 with open(fov_dir.joinpath('mpciROIs.masks.sparse_npz'), 'wb') as fp: 1dc
335 sparse.save_npz(fp, roi_mask) 1dc
336 with open(fov_dir.joinpath('mpciROIs.neuropilMasks.sparse_npz'), 'wb') as fp: 1dc
337 sparse.save_npz(fp, pil_mask) 1dc
338 # move folders out of suite2p dir
339 # We overwrite existing files
340 for file in filter(lambda x: is_valid(x.name), fov_dir.iterdir()): 1dc
341 target_file = target.joinpath(file.name) 1dc
342 if target_file.exists(): 1dc
343 target_file.unlink()
344 file.rename(target_file) 1dc
345 shutil.rmtree(str(suite2p_dir), ignore_errors=False, onerror=None) 1dc
346 # Collect all files in those directories
347 return list(suite2p_dir.parent.rglob('FOV*/*')) 1dc
349 @staticmethod
350 def _check_meta_data(meta_data_all: list) -> dict:
351 """
352 Check that the meta data is consistent across all raw imaging folders.
354 Parameters
355 ----------
356 meta_data_all: list of dicts
357 List of metadata dictionaries to be checked for consistency.
359 Returns
360 -------
361 dict
362 Single, consolidated dictionary containing metadata.
363 """
364 # Ignore the things we don't expect to match
365 ignore = ('acquisitionStartTime', 'nFrames')
366 ignore_sub = {'rawScanImageMeta': ('ImageDescription', 'Software')}
368 def equal_dicts(a, b, skip=None):
369 ka = set(a).difference(skip or ())
370 kb = set(b).difference(skip or ())
371 return ka == kb and all(a[key] == b[key] for key in ka)
373 # Compare each dict with the first one in the list
374 for i, meta in enumerate(meta_data_all[1:]):
375 if meta != meta_data_all[0]: # compare entire object first
376 for k, v in meta_data_all[0].items(): # check key by key
377 if not (equal_dicts(v, meta[k], ignore_sub[k]) # compare sub-dicts...
378 if k in ignore_sub else # ... if we have keys to ignore in test
379 not (k in ignore or v == meta[k])):
380 _logger.warning(f'Mismatch in meta data between raw_imaging_data folders for key {k}. '
381 f'Using meta_data from first folder!')
382 return meta_data_all[0]
384 @staticmethod
385 def _consolidate_exptQC(exptQC):
386 """
387 Consolidate exptQC.mat files into a single file.
389 Parameters
390 ----------
391 exptQC : list of pandas.DataFrame
392 The loaded 'exptQC.mat' files as squeezed and simplified data frames, with columns
393 {'frameQC_frames', 'frameQC_names'}.
395 Returns
396 -------
397 numpy.array
398 An array of uint8 where 0 indicates good frames, and other values correspond to
399 experimenter-defined issues (in 'qc_values' column of output data frame).
400 pandas.DataFrame
401 A data frame with columns {'qc_values', 'qc_labels'}, the former an unsigned int
402 corresponding to a QC code; the latter a human-readable QC explanation.
403 numpy.array
404 An array of frame indices where QC code != 0.
405 """
407 # Merge and make sure same indexes have same names across all files
408 frameQC_names_list = [e['frameQC_names'] for e in exptQC]
409 frameQC_names_list = [{f: 0} if isinstance(f, str) else {f[i]: i for i in range(len(f))}
410 for f in frameQC_names_list]
411 frameQC_names = {k: v for d in frameQC_names_list for k, v in d.items()}
412 for d in frameQC_names_list:
413 for k, v in d.items():
414 if frameQC_names[k] != v:
415 raise IOError(f'exptQC.mat files have different values for name "{k}"')
417 frameQC_names = pd.DataFrame(sorted([(v, k) for k, v in frameQC_names.items()]),
418 columns=['qc_values', 'qc_labels'])
420 # Concatenate frames
421 frameQC = np.concatenate([e['frameQC_frames'] for e in exptQC], axis=0)
423 # Transform to bad_frames as expected by suite2p
424 bad_frames = np.where(frameQC != 0)[0]
426 return frameQC, frameQC_names, bad_frames
428 def get_default_tau(self):
429 """
430 Determine the tau (fluorescence decay) from the subject's genotype.
432 Returns
433 -------
434 float
435 The tau value to use.
437 See Also
438 --------
439 https://suite2p.readthedocs.io/en/latest/settings.html
440 """
441 # These settings are from the suite2P documentation
442 TAU_MAP = {'G6s': 1.5, 'G6m': 1., 'G6f': .7, 'default': 1.5} 1m
443 _, subject, *_ = session_path_parts(self.session_path) 1m
444 genotype = self.one.alyx.rest('subjects', 'read', id=subject)['genotype'] 1m
445 match = next(filter(None, (re.match(r'.+-(G\d[fms])$', g['allele']) for g in genotype)), None) 1m
446 key = match.groups()[0] if match else 'default' 1m
447 return TAU_MAP.get(key, TAU_MAP['default']) 1m
449 def _create_db(self, meta):
450 """
451 Create the ops dictionary for suite2p based on metadata.
453 Parameters
454 ----------
455 meta: dict
456 Imaging metadata.
458 Returns
459 -------
460 dict
461 Inputs to suite2p run that deviate from default parameters.
462 """
464 # Currently only supporting single plane, assert that this is the case
465 # FIXME This checks for zstacks but not dual plane mode
466 if not isinstance(meta['scanImageParams']['hStackManager']['zs'], int): 1f
467 raise NotImplementedError('Multi-plane imaging not yet supported, data seems to be multi-plane')
469 # Computing dx and dy
470 cXY = np.array([fov['topLeftDeg'] for fov in meta['FOV']]) 1f
471 cXY -= np.min(cXY, axis=0) 1f
472 nXnYnZ = np.array([fov['nXnYnZ'] for fov in meta['FOV']]) 1f
473 sW = np.sqrt(np.sum((np.array([fov['topRightDeg'] for fov in meta['FOV']]) - np.array( 1f
474 [fov['topLeftDeg'] for fov in meta['FOV']])) ** 2, axis=1))
475 sH = np.sqrt(np.sum((np.array([fov['bottomLeftDeg'] for fov in meta['FOV']]) - np.array( 1f
476 [fov['topLeftDeg'] for fov in meta['FOV']])) ** 2, axis=1))
477 pixSizeX = nXnYnZ[:, 0] / sW 1f
478 pixSizeY = nXnYnZ[:, 1] / sH 1f
479 dx = np.round(cXY[:, 0] * pixSizeX).astype(dtype=np.int32) 1f
480 dy = np.round(cXY[:, 1] * pixSizeY).astype(dtype=np.int32) 1f
481 nchannels = len(meta['channelSaved']) if isinstance(meta['channelSaved'], list) else 1 1f
483 db = { 1f
484 'data_path': sorted(map(str, self.session_path.glob(f'{self.device_collection}'))),
485 'save_path0': str(self.session_path.joinpath('alf')),
486 'fast_disk': '', # TODO
487 'look_one_level_down': False, # don't look in the children folders as that is where the reference data is
488 'num_workers': self.cpu, # this selects number of cores to parallelize over for the registration step
489 'num_workers_roi': -1, # for parallelization over FOVs during cell detection, for now don't
490 'keep_movie_raw': False,
491 'delete_bin': False, # TODO: delete this on the long run
492 'batch_size': 500, # SP reduced this from 1000
493 'nimg_init': 400,
494 'combined': False,
495 'nonrigid': True,
496 'maxregshift': 0.05, # default = 1
497 'denoise': 1, # whether binned movie should be denoised before cell detection
498 'block_size': [128, 128],
499 'save_mat': True, # save the data to Fall.mat
500 'move_bin': True, # move the binary file to save_path
501 'scalefactor': 1, # scale manually in x to account for overlap between adjacent ribbons UCL mesoscope
502 'mesoscan': True,
503 'nplanes': 1,
504 'nrois': len(meta['FOV']),
505 'nchannels': nchannels,
506 'fs': meta['scanImageParams']['hRoiManager']['scanVolumeRate'],
507 'lines': [list(np.asarray(fov['lineIdx']) - 1) for fov in meta['FOV']], # subtracting 1 to make 0-based
508 'tau': self.get_default_tau(), # deduce the GCamp used from Alyx mouse line (defaults to 1.5; that of GCaMP6s)
509 'functional_chan': 1, # for now, eventually find(ismember(meta.channelSaved == meta.channelID.green))
510 'align_by_chan': 1, # for now, eventually find(ismember(meta.channelSaved == meta.channelID.red))
511 'dx': dx,
512 'dy': dy
513 }
515 return db 1f
517 def _run(self, run_suite2p=True, rename_files=True, use_badframes=True, **kwargs):
518 """
519 Process inputs, run suite2p and make outputs alf compatible.
521 Parameters
522 ----------
523 run_suite2p: bool
524 Whether to run suite2p, default is True.
525 rename_files: bool
526 Whether to rename and reorganize the suite2p outputs to be alf compatible. Defaults is True.
527 use_badframes: bool
528 Whether to exclude bad frames indicated by the experimenter in exptQC.mat. Default is currently False
529 due to bug in suite2p. Change this in the future.
531 Returns
532 -------
533 list of pathlib.Path
534 All files created by the task.
535 """
536 import suite2p 1f
538 """ Metadata and parameters """ 1f
539 # Load metadata and make sure all metadata is consistent across FOVs
540 meta_files = sorted(self.session_path.glob(f'{self.device_collection}/*rawImagingData.meta.*')) 1f
541 collections = set(f.parts[-2] for f in meta_files) 1f
542 # Check there is exactly 1 meta file per collection
543 assert len(meta_files) == len(list(self.session_path.glob(self.device_collection))) == len(collections) 1f
544 rawImagingData = [mesoscope.patch_imaging_meta(alfio.load_file_content(filepath)) for filepath in meta_files] 1f
545 if len(rawImagingData) > 1: 1f
546 meta = self._check_meta_data(rawImagingData)
547 else:
548 meta = rawImagingData[0] 1f
549 # Get default ops
550 ops = suite2p.default_ops() 1f
551 # Create db which overwrites ops when passed to suite2p, with information from meta data and hardcoded
552 db = self._create_db(meta) 1f
553 # Anything can be overwritten by keyword arguments passed to the tasks run() method
554 for k, v in kwargs.items(): 1f
555 if k in ops.keys() or k in db.keys(): 1f
556 # db overwrites ops when passed to run_s2p, so we only need to update / add it here
557 db[k] = v 1f
558 # Update the task kwargs attribute as it will be stored in the arguments json field in alyx
559 self.kwargs = {**self.kwargs, **db} 1f
561 """ Bad frames """ 1f
562 qc_paths = (self.session_path.joinpath(f[1], 'exptQC.mat') 1f
563 for f in self.input_files if f[0] == 'exptQC.mat')
564 qc_paths = map(str, filter(Path.exists, qc_paths)) 1f
565 exptQC = [loadmat(p, squeeze_me=True, simplify_cells=True) for p in qc_paths] 1f
566 if len(exptQC) > 0: 1f
567 frameQC, frameQC_names, bad_frames = self._consolidate_exptQC(exptQC)
568 else:
569 _logger.warning('No frame QC (exptQC.mat) files found.') 1f
570 frameQC, bad_frames = np.array([], dtype='u1'), np.array([], dtype='i8') 1f
571 frameQC_names = pd.DataFrame(columns=['qc_values', 'qc_labels']) 1f
572 # If applicable, save as bad_frames.npy in first raw_imaging_folder for suite2p
573 if len(bad_frames) > 0 and use_badframes is True: 1f
574 np.save(Path(db['data_path'][0]).joinpath('bad_frames.npy'), bad_frames)
576 """ Suite2p """ 1f
577 # Create alf it is doesn't exist
578 self.session_path.joinpath('alf').mkdir(exist_ok=True) 1f
579 # Remove existing suite2p dir if it exists
580 suite2p_dir = Path(db['save_path0']).joinpath('suite2p') 1f
581 if suite2p_dir.exists(): 1f
582 shutil.rmtree(str(suite2p_dir), ignore_errors=True, onerror=None)
583 # Run suite2p
584 if run_suite2p: 1f
585 _ = suite2p.run_s2p(ops=ops, db=db)
587 """ Outputs """ 1f
588 # Save and rename other outputs
589 if rename_files: 1f
590 out_files = self._rename_outputs(suite2p_dir, frameQC_names, frameQC)
591 else:
592 out_files = list(Path(db['save_path0']).joinpath('suite2p').rglob('*')) 1f
593 # Only return output file that are in the signature (for registration)
594 out_files = [f for f in out_files if f.name in [f[0] for f in self.output_files]] 1f
595 return out_files 1f
598class MesoscopeSync(base_tasks.MesoscopeTask):
599 """Extract the frame times from the main DAQ."""
601 priority = 40
602 job_size = 'small'
604 @property
605 def signature(self):
606 signature = { 1ij
607 'input_files': [(f'_{self.sync_namespace}_DAQdata.raw.npy', self.sync_collection, True),
608 (f'_{self.sync_namespace}_DAQdata.timestamps.npy', self.sync_collection, True),
609 (f'_{self.sync_namespace}_DAQdata.meta.json', self.sync_collection, True),
610 ('_ibl_rawImagingData.meta.json', self.device_collection, True),
611 ('rawImagingData.times_scanImage.npy', self.device_collection, True),
612 (f'_{self.sync_namespace}_softwareEvents.log.htsv', self.sync_collection, False), ],
613 'output_files': [('mpci.times.npy', 'alf/mesoscope/FOV*', True),
614 ('mpciStack.timeshift.npy', 'alf/mesoscope/FOV*', True), ]
615 }
616 return signature 1ij
618 def _run(self):
619 """
620 Extract the imaging times for all FOVs.
622 Returns
623 -------
624 list of pathlib.Path
625 Files containing frame timestamps for individual FOVs and time offsets for each line scan.
627 """
628 # TODO function to determine nFOVs
629 try: 1ij
630 alf_path = self.session_path / self.sync_collection 1ij
631 events = alfio.load_object(alf_path, 'softwareEvents').get('log') 1ij
632 except alferr.ALFObjectNotFound: 1i
633 events = None 1i
634 if events is None or events.empty: 1ij
635 _logger.debug('No software events found for session %s', self.session_path) 1i
636 collections = set(collection for _, collection, _ in self.input_files 1ij
637 if fnmatch(collection, self.device_collection))
638 # Load first meta data file to determine the number of FOVs
639 # Changing FOV between imaging bouts is not supported currently!
640 self.rawImagingData = alfio.load_object(self.session_path / next(iter(collections)), 'rawImagingData') 1ij
641 self.rawImagingData['meta'] = mesoscope.patch_imaging_meta(self.rawImagingData['meta']) 1ij
642 n_FOVs = len(self.rawImagingData['meta']['FOV']) 1ij
643 sync, chmap = self.load_sync() # Extract sync data from raw DAQ data 1ij
644 mesosync = mesoscope.MesoscopeSyncTimeline(self.session_path, n_FOVs) 1ij
645 _, out_files = mesosync.extract( 1ij
646 save=True, sync=sync, chmap=chmap, device_collection=collections, events=events)
647 return out_files 1ij
650class MesoscopeFOV(base_tasks.MesoscopeTask):
651 """Create FOV and FOV location objects in Alyx from metadata."""
653 priority = 40
654 job_size = 'small'
656 @property
657 def signature(self):
658 signature = { 1g
659 'input_files': [('_ibl_rawImagingData.meta.json', self.device_collection, True),
660 ('mpciROIs.stackPos.npy', 'alf/FOV*', True)],
661 'output_files': [('mpciMeanImage.brainLocationIds*.npy', 'alf/FOV_*', True),
662 ('mpciMeanImage.mlapdv*.npy', 'alf/FOV_*', True),
663 ('mpciROIs.mlapdv*.npy', 'alf/FOV_*', True),
664 ('mpciROIs.brainLocationIds*.npy', 'alf/FOV_*', True),
665 ('_ibl_rawImagingData.meta.json', self.device_collection, True)]
666 }
667 return signature 1g
669 def _run(self, *args, provenance=Provenance.ESTIMATE, **kwargs):
670 """
671 Register fields of view (FOV) to Alyx and extract the coordinates and IDs of each ROI.
673 Steps:
674 1. Save the mpciMeanImage.brainLocationIds_estimate and mlapdv datasets.
675 2. Use mean image coordinates and ROI stack position datasets to extract brain location
676 of each ROI.
677 3. Register the location of each FOV in Alyx.
679 Parameters
680 ----------
681 provenance : Provenance
682 The provenance of the coordinates in the meta file. For all but 'HISTOLOGY', the
683 provenance is added as a dataset suffix. Defaults to ESTIMATE.
685 Returns
686 -------
687 dict
688 The newly created FOV Alyx record.
689 list
690 The newly created FOV location Alyx records.
692 Notes
693 -----
694 Once the FOVs have been registered they cannot be updated with with task. Rerunning this
695 task will result in an error.
696 """
697 # Load necessary data
698 (filename, collection, _), *_ = self.signature['input_files'] 1g
699 meta_file = next(self.session_path.glob(f'{collection}/{filename}'), None) 1g
700 meta = alfio.load_file_content(meta_file) or {} 1g
701 nFOV = len(meta.get('FOV', [])) 1g
703 suffix = None if provenance is Provenance.HISTOLOGY else provenance.name.lower() 1g
704 _logger.info('Extracting %s MLAPDV datasets', suffix or 'final') 1g
706 # Extract mean image MLAPDV coordinates and brain location IDs
707 mean_image_mlapdv, mean_image_ids = self.project_mlapdv(meta) 1g
709 # Save the meta data file with new coordinate fields
710 with open(meta_file, 'w') as fp: 1g
711 json.dump(meta, fp) 1g
713 # Save the mean image datasets
714 mean_image_files = [] 1g
715 assert set(mean_image_mlapdv.keys()) == set(mean_image_ids.keys()) and len(mean_image_ids) == nFOV 1g
716 for i in range(nFOV): 1g
717 alf_path = self.session_path.joinpath('alf', f'FOV_{i:02}') 1g
718 for attr, arr, sfx in (('mlapdv', mean_image_mlapdv[i], suffix), 1g
719 ('brainLocationIds', mean_image_ids[i], ('ccf', '2017', suffix))):
720 mean_image_files.append(alf_path / to_alf('mpciMeanImage', attr, 'npy', timescale=sfx)) 1g
721 np.save(mean_image_files[-1], arr) 1g
723 # Extract ROI MLAPDV coordinates and brain location IDs
724 roi_mlapdv, roi_brain_ids = self.roi_mlapdv(nFOV, suffix=suffix) 1g
726 # Write MLAPDV + brain location ID of ROIs to disk
727 roi_files = [] 1g
728 assert set(roi_mlapdv.keys()) == set(roi_brain_ids.keys()) and len(roi_mlapdv) == nFOV 1g
729 for i in range(nFOV): 1g
730 alf_path = self.session_path.joinpath('alf', f'FOV_{i:02}') 1g
731 for attr, arr, sfx in (('mlapdv', roi_mlapdv[i], suffix), 1g
732 ('brainLocationIds', roi_brain_ids[i], ('ccf', '2017', suffix))):
733 roi_files.append(alf_path / to_alf('mpciROIs', attr, 'npy', timescale=sfx)) 1g
734 np.save(roi_files[-1], arr) 1g
736 # Register FOVs in Alyx
737 self.register_fov(meta, suffix) 1g
739 return sorted([meta_file, *roi_files, *mean_image_files]) 1g
741 def roi_mlapdv(self, nFOV: int, suffix=None):
742 """
743 Extract ROI MLAPDV coordinates and brain location IDs.
745 MLAPDV coordinates are in um relative to bregma. Location IDs are from the 2017 Allen
746 common coordinate framework atlas.
748 Parameters
749 ----------
750 nFOV : int
751 The number of fields of view acquired.
752 suffix : {None, 'estimate'}
753 The attribute suffix of the mpciMeanImage datasets to load. If generating from
754 estimates, the suffix should be 'estimate'.
756 Returns
757 -------
758 dict of int: numpy.array
759 A map of field of view to ROI MLAPDV coordinates.
760 dict of int: numpy.array
761 A map of field of view to ROI brain location IDs.
762 """
763 all_mlapdv = {} 1g
764 all_brain_ids = {} 1g
765 for n in range(nFOV): 1g
766 alf_path = self.session_path.joinpath('alf', f'FOV_{n:02}') 1g
768 # Load neuron centroids in pixel space
769 stack_pos_file = next(alf_path.glob('mpciROIs.stackPos*'), None) 1g
770 if not stack_pos_file: 1g
771 raise FileNotFoundError(alf_path / 'mpci.stackPos*') 1g
772 stack_pos = alfio.load_file_content(stack_pos_file) 1g
774 # Load MLAPDV + brain location ID maps of pixels
775 mpciMeanImage = alfio.load_object( 1g
776 alf_path, 'mpciMeanImage', attribute=['mlapdv', 'brainLocationIds'])
778 # Get centroid MLAPDV + brainID by indexing pixel-map with centroid locations
779 mlapdv = np.full(stack_pos.shape, np.nan) 1g
780 brain_ids = np.full(stack_pos.shape[0], np.nan) 1g
781 for i in np.arange(stack_pos.shape[0]): 1g
782 idx = (stack_pos[i, 0], stack_pos[i, 1]) 1g
783 sfx = f'_{suffix}' if suffix else '' 1g
784 mlapdv[i, :] = mpciMeanImage['mlapdv' + sfx][idx] 1g
785 brain_ids[i] = mpciMeanImage['brainLocationIds_ccf_2017' + sfx][idx] 1g
786 assert ~np.isnan(brain_ids).any() 1g
787 all_brain_ids[n] = brain_ids.astype(int) 1g
788 all_mlapdv[n] = mlapdv 1g
790 return all_mlapdv, all_brain_ids 1g
792 @staticmethod
793 def get_provenance(filename):
794 """
795 Get the field of view provenance from a mpciMeanImage or mpciROIs dataset.
797 Parameters
798 ----------
799 filename : str, pathlib.Path
800 A filename to get the provenance from.
802 Returns
803 -------
804 Provenance
805 The provenance of the file.
806 """
807 filename = Path(filename).name 1n
808 timescale = (filename_parts(filename)[3] or '').split('_') 1n
809 provenances = [i.name.lower() for i in Provenance] 1n
810 provenance = (Provenance[x.upper()] for x in timescale if x in provenances) 1n
811 return next(provenance, None) or Provenance.HISTOLOGY 1n
813 def register_fov(self, meta: dict, suffix: str = None) -> (list, list):
814 """
815 Create FOV on Alyx.
817 Assumes field of view recorded perpendicular to objective.
818 Assumes field of view is plane (negligible volume).
820 Required Alyx fixtures:
821 - experiments.ImagingType(name='mesoscope')
822 - experiments.CoordinateSystem(name='IBL-Allen')
824 Parameters
825 ----------
826 meta : dict
827 The raw imaging meta data from _ibl_rawImagingData.meta.json.
828 suffix : str
829 The file attribute suffixes to load from the mpciMeanImage object. Either 'estimate' or
830 None. No suffix means the FOV location provenance will be L (Landmark).
832 Returns
833 -------
834 list of dict
835 A list registered of field of view entries from Alyx.
837 TODO Determine dual plane ID for JSON field
838 """
839 dry = self.one is None or self.one.offline 1h
840 alyx_fovs = [] 1h
841 # Count the number of slices per stack ID: only register stacks that contain more than one slice.
842 slice_counts = Counter(f['roiUUID'] for f in meta.get('FOV', [])) 1h
843 # Create a new stack in Alyx for all stacks containing more than one slice.
844 # Map of ScanImage ROI UUID to Alyx ImageStack UUID.
845 stack_ids = {i: self.one.alyx.rest('imaging-stack', 'create', data={'name': i})['id'] 1h
846 for i in slice_counts if slice_counts[i] > 1}
848 for i, fov in enumerate(meta.get('FOV', [])): 1h
849 assert set(fov.keys()) >= {'MLAPDV', 'nXnYnZ', 'roiUUID'} 1h
850 # Field of view
851 alyx_FOV = { 1h
852 'session': self.session_path.as_posix() if dry else self.path2eid(),
853 'imaging_type': 'mesoscope', 'name': f'FOV_{i:02}',
854 'stack': stack_ids.get(fov['roiUUID'])
855 }
856 if dry: 1h
857 print(alyx_FOV) 1h
858 alyx_FOV['location'] = [] 1h
859 alyx_fovs.append(alyx_FOV) 1h
860 else:
861 alyx_fovs.append(self.one.alyx.rest('fields-of-view', 'create', data=alyx_FOV)) 1h
863 # Field of view location
864 data = {'field_of_view': alyx_fovs[-1].get('id'), 1h
865 'default_provenance': True,
866 'coordinate_system': 'IBL-Allen',
867 'n_xyz': fov['nXnYnZ']}
868 if suffix: 1h
869 data['provenance'] = suffix[0].upper() 1h
871 # Convert coordinates to 4 x 3 array (n corners by n dimensions)
872 # x1 = top left ml, y1 = top left ap, y2 = top right ap, etc.
873 coords = [fov['MLAPDV'][key] for key in ('topLeft', 'topRight', 'bottomLeft', 'bottomRight')] 1h
874 coords = np.vstack(coords).T 1h
875 data.update({k: arr.tolist() for k, arr in zip('xyz', coords)}) 1h
877 # Load MLAPDV + brain location ID maps of pixels
878 filename = 'mpciMeanImage.brainLocationIds_ccf_2017' + (f'_{suffix}' if suffix else '') + '.npy' 1h
879 filepath = self.session_path.joinpath('alf', f'FOV_{i:02}', filename) 1h
880 mean_image_ids = alfio.load_file_content(filepath) 1h
882 data['brain_region'] = np.unique(mean_image_ids).astype(int).tolist() 1h
884 if dry: 1h
885 print(data) 1h
886 alyx_FOV['location'].append(data) 1h
887 else:
888 alyx_fovs[-1]['location'].append(self.one.alyx.rest('fov-location', 'create', data=data)) 1h
889 return alyx_fovs 1h
891 def load_triangulation(self):
892 """
893 Load the surface triangulation file.
895 A triangle mesh of the smoothed convex hull of the dorsal surface of the mouse brain,
896 generated from the 2017 Allen 10um annotation volume. This triangulation was generated in
897 MATLAB.
899 Returns
900 -------
901 points : numpy.array
902 An N by 3 float array of x-y vertices, defining all points of the triangle mesh. These
903 are in um relative to the IBL bregma coordinates.
904 connectivity_list : numpy.array
905 An N by 3 integer array of vertex indices defining all points that form a triangle.
906 """
907 fixture_path = Path(mesoscope.__file__).parent.joinpath('mesoscope') 1b
908 surface_triangulation = np.load(fixture_path / 'surface_triangulation.npz') 1b
909 points = surface_triangulation['points'].astype('f8') 1b
910 connectivity_list = surface_triangulation['connectivity_list'] 1b
911 surface_triangulation.close() 1b
912 return points, connectivity_list 1b
914 def project_mlapdv(self, meta, atlas=None):
915 """
916 Calculate the mean image pixel locations in MLAPDV coordinates and determine the brain
917 location IDs.
919 MLAPDV coordinates are in um relative to bregma. Location IDs are from the 2017 Allen
920 common coordinate framework atlas.
922 Parameters
923 ----------
924 meta : dict
925 The raw imaging data meta file, containing coordinates for the centre of each field of
926 view.
927 atlas : ibllib.atlas.Atlas
928 An atlas instance.
930 Returns
931 -------
932 dict
933 A map of FOV number (int) to mean image MLAPDV coordinates as a 2D numpy array.
934 dict
935 A map of FOV number (int) to mean image brain location IDs as a 2D numpy int array.
936 """
937 mlapdv = {} 1b
938 location_id = {} 1b
939 # Use the MRI atlas as this applies scaling, particularly along the DV axis to (hopefully)
940 # more accurately represent the living brain.
941 atlas = atlas or MRITorontoAtlas(res_um=10) 1b
942 # The centre of the craniotomy / imaging window
943 coord_ml = meta['centerMM']['ML'] * 1e3 # mm -> um 1b
944 coord_ap = meta['centerMM']['AP'] * 1e3 # mm -> um 1b
945 pt = np.array([coord_ml, coord_ap]) 1b
947 points, connectivity_list = self.load_triangulation() 1b
948 # Only keep faces that have normals pointing up (positive DV value).
949 # Calculate the normal vector pointing out of the convex hull.
950 triangles = points[connectivity_list, :] 1b
951 normals = surface_normal(triangles) 1b
952 up_faces, = np.where(normals[:, -1] > 0) 1b
953 # only keep triangles that have normal vector with positive DV component
954 dorsal_connectivity_list = connectivity_list[up_faces, :] 1b
955 # Flatten triangulation by dropping the dorsal coordinates and find the location of the
956 # window center (we convert mm -> um here)
957 face_ind = find_triangle(pt * 1e-3, points[:, :2] * 1e-3, dorsal_connectivity_list.astype(np.intp)) 1b
958 assert face_ind != -1 1b
960 dorsal_triangle = points[dorsal_connectivity_list[face_ind, :], :] 1b
962 # Get the surface normal unit vector of dorsal triangle
963 normal_vector = surface_normal(dorsal_triangle) 1b
965 # find the coordDV that sits on the triangular face and had [coordML, coordAP] coordinates;
966 # the three vertices defining the triangle
967 face_vertices = points[dorsal_connectivity_list[face_ind, :], :] 1b
969 # all the vertices should be on the plane ax + by + cz = 1, so we can find
970 # the abc coefficients by inverting the three equations for the three vertices
971 abc, *_ = np.linalg.lstsq(face_vertices, np.ones(3), rcond=None) 1b
973 # and then find a point on that plane that corresponds to a given x-y
974 # coordinate (which is ML-AP coordinate)
975 coord_dv = (1 - pt @ abc[:2]) / abc[2] 1b
977 # We should not use the actual surface of the brain for this, as it might be in one of the sulci
978 # DO NOT USE THIS:
979 # coordDV = interp2(axisMLmm, axisAPmm, surfaceDV, coordML, coordAP)
981 # Now we need to span the plane of the coverslip with two orthogonal unit vectors.
982 # We start with vY, because the order is important and we usually have less
983 # tilt along AP (pitch), which will cause less deviation in vX from pure ML.
984 vY = np.array([0, normal_vector[2], -normal_vector[1]]) # orthogonal to the normal of the plane 1b
985 vX = np.cross(vY, normal_vector) # orthogonal to n and to vY 1b
986 # normalize and flip the sign if necessary
987 vX = vX / np.sqrt(vX @ vX) * np.sign(vX[0]) # np.sqrt(vY @ vY) == LR norm of vX 1b
988 vY = vY / np.sqrt(vY @ vY) * np.sign(vY[1]) 1b
990 # what are the dimensions of the data arrays (ap, ml, dv)
991 (nAP, nML, nDV) = atlas.image.shape 1b
992 # Let's shift the coordinates relative to bregma
993 voxel_size = atlas.res_um # [um] resolution of the atlas 1b
994 bregma_coords = ALLEN_CCF_LANDMARKS_MLAPDV_UM['bregma'] / voxel_size # (ml, ap, dv) 1b
995 axis_ml_um = (np.arange(nML) - bregma_coords[0]) * voxel_size 1b
996 axis_ap_um = (np.arange(nAP) - bregma_coords[1]) * voxel_size * -1. 1b
997 axis_dv_um = (np.arange(nDV) - bregma_coords[2]) * voxel_size * -1. 1b
999 # projection of FOVs on the brain surface to get ML-AP-DV coordinates
1000 _logger.info('Projecting in 3D') 1b
1001 for i, fov in enumerate(meta['FOV']): # i, fov = next(enumerate(meta['FOV'])) 1b
1002 start_time = time.time() 1b
1003 _logger.info(f'FOV {i + 1}/{len(meta["FOV"])}') 1b
1004 y_px_idx, x_px_idx = np.mgrid[0:fov['nXnYnZ'][0], 0:fov['nXnYnZ'][1]] 1b
1006 # xx and yy are in mm in coverslip space
1007 points = ((0, fov['nXnYnZ'][0] - 1), (0, fov['nXnYnZ'][1] - 1)) 1b
1008 if 'MM' not in fov: 1b
1009 fov['MM'] = {
1010 'topLeft': fov.pop('topLeftMM'),
1011 'topRight': fov.pop('topRightMM'),
1012 'bottomLeft': fov.pop('bottomLeftMM'),
1013 'bottomRight': fov.pop('bottomRightMM')
1014 }
1015 # The four corners of the FOV, determined by taking the center of the craniotomy in MM,
1016 # the x-y coordinates of the imaging window center (from the tiled reference image) in
1017 # galvanometer units, and the x-y coordinates of the FOV center in galvanometer units.
1018 values = [[fov['MM']['topLeft'][0], fov['MM']['topRight'][0]], 1b
1019 [fov['MM']['bottomLeft'][0], fov['MM']['bottomRight'][0]]]
1020 values = np.array(values) * 1e3 # mm -> um 1b
1021 xx = interpn(points, values, (y_px_idx, x_px_idx)) 1b
1023 values = [[fov['MM']['topLeft'][1], fov['MM']['topRight'][1]], 1b
1024 [fov['MM']['bottomLeft'][1], fov['MM']['bottomRight'][1]]]
1025 values = np.array(values) * 1e3 # mm -> um 1b
1026 yy = interpn(points, values, (y_px_idx, x_px_idx)) 1b
1028 xx = xx.flatten() - coord_ml 1b
1029 yy = yy.flatten() - coord_ap 1b
1031 # rotate xx and yy in 3D
1032 # the coords are still on the coverslip, but now have 3D values
1033 coords = np.outer(xx, vX) + np.outer(yy, vY) # (vX * xx) + (vY * yy) 1b
1034 coords = coords + [coord_ml, coord_ap, coord_dv] 1b
1036 # for each point of the FOV create a line parametrization (trajectory normal to the coverslip plane).
1037 # start just above the coverslip and go 3 mm down, should be enough to 'meet' the brain
1038 t = np.arange(-voxel_size, 3e3, voxel_size) 1b
1040 # Find the MLAPDV atlas coordinate and brain location of each pixel.
1041 MLAPDV, annotation = _update_points( 1b
1042 t, normal_vector, coords, axis_ml_um, axis_ap_um, axis_dv_um, atlas.label)
1043 annotation = atlas.regions.index2id(annotation) # convert annotation indices to IDs 1b
1045 if np.any(np.isnan(MLAPDV)): 1b
1046 _logger.warning('Areas of FOV lie outside the brain') 1b
1047 _logger.info(f'done ({time.time() - start_time:3.1f} seconds)\n') 1b
1048 MLAPDV = np.reshape(MLAPDV, [*x_px_idx.shape, 3]) 1b
1049 annotation = np.reshape(annotation, x_px_idx.shape) 1b
1051 fov['MLAPDV'] = { 1b
1052 'topLeft': MLAPDV[0, 0, :].tolist(),
1053 'topRight': MLAPDV[0, -1, :].tolist(),
1054 'bottomLeft': MLAPDV[-1, 0, :].tolist(),
1055 'bottomRight': MLAPDV[-1, -1, :].tolist(),
1056 'center': MLAPDV[round(x_px_idx.shape[0] / 2) - 1, round(x_px_idx.shape[1] / 2) - 1, :].tolist()
1057 }
1059 # Save the brain regions of the corners/centers of FOV (annotation field)
1060 fov['brainLocationIds'] = { 1b
1061 'topLeft': int(annotation[0, 0]),
1062 'topRight': int(annotation[0, -1]),
1063 'bottomLeft': int(annotation[-1, 0]),
1064 'bottomRight': int(annotation[-1, -1]),
1065 'center': int(annotation[round(x_px_idx.shape[0] / 2) - 1, round(x_px_idx.shape[1] / 2) - 1])
1066 }
1068 mlapdv[i] = MLAPDV 1b
1069 location_id[i] = annotation 1b
1070 return mlapdv, location_id 1b
1073def surface_normal(triangle):
1074 """
1075 Calculate the surface normal unit vector of one or more triangles.
1077 Parameters
1078 ----------
1079 triangle : numpy.array
1080 An array of shape (n_triangles, 3, 3) representing (Px Py Pz).
1082 Returns
1083 -------
1084 numpy.array
1085 The surface normal unit vector(s).
1086 """
1087 if triangle.shape == (3, 3): 1kb
1088 triangle = triangle[np.newaxis, :, :] 1kb
1089 if triangle.shape[1:] != (3, 3): 1kb
1090 raise ValueError('expected array of shape (3, 3); 3 coordinates in x, y, and z') 1k
1091 V = triangle[:, 1, :] - triangle[:, 0, :] # V = P2 - P1 1kb
1092 W = triangle[:, 2, :] - triangle[:, 0, :] # W = P3 - P1 1kb
1094 Nx = (V[:, 1] * W[:, 2]) - (V[:, 2] * W[:, 1]) # Nx = (Vy * Wz) - (Vz * Wy) 1kb
1095 Ny = (V[:, 2] * W[:, 0]) - (V[:, 0] * W[:, 2]) # Ny = (Vz * Wx) - (Vx * Wz) 1kb
1096 Nz = (V[:, 0] * W[:, 1]) - (V[:, 1] * W[:, 0]) # Nz = (Vx * Wy) - (Vy * Wx) 1kb
1097 N = np.c_[Nx, Ny, Nz] 1kb
1098 # Calculate unit vector. Transpose allows vectorized operation.
1099 A = N / np.sqrt((Nx ** 2) + (Ny ** 2) + (Nz ** 2))[np.newaxis].T 1kb
1100 return A.squeeze() 1kb
1103@nb.njit('b1(f8[:,:], f8[:])')
1104def in_triangle(triangle, point):
1105 """
1106 Check whether `point` lies within `triangle`.
1108 Parameters
1109 ----------
1110 triangle : numpy.array
1111 A (2 x 3) array of x-y coordinates; A(x1, y1), B(x2, y2) and C(x3, y3).
1112 point : numpy.array
1113 A point, P(x, y).
1115 Returns
1116 -------
1117 bool
1118 True if coordinate lies within triangle.
1119 """
1120 def area(x1, y1, x2, y2, x3, y3):
1121 """Calculate the area of a triangle, given its vertices."""
1122 return abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2.)
1124 x1, y1, x2, y2, x3, y3 = triangle.flat
1125 x, y = point
1126 A = area(x1, y1, x2, y2, x3, y3) # area of triangle ABC
1127 A1 = area(x, y, x2, y2, x3, y3) # area of triangle PBC
1128 A2 = area(x1, y1, x, y, x3, y3) # area of triangle PAC
1129 A3 = area(x1, y1, x2, y2, x, y) # area of triangle PAB
1130 # Check if sum of A1, A2 and A3 equals that of A
1131 diff = np.abs((A1 + A2 + A3) - A)
1132 REL_TOL = 1e-9
1133 return diff <= np.abs(REL_TOL * A) # isclose not yet implemented in numba 0.57
1136@nb.njit('i8(f8[:], f8[:,:], intp[:,:])', nogil=True)
1137def find_triangle(point, vertices, connectivity_list):
1138 """
1139 Find which vertices contain a given point.
1141 Currently O(n) but could take advantage of connectivity order to be quicker.
1143 Parameters
1144 ----------
1145 point : numpy.array
1146 The (x, y) coordinate of a point to locate within one of the triangles.
1147 vertices : numpy.array
1148 An N x 3 array of vertices representing a triangle mesh.
1149 connectivity_list : numpy.array
1150 An N x 3 array of indices representing the connectivity of `points`.
1152 Returns
1153 -------
1154 int
1155 The index of the vertices containing `point`, or -1 if not within any triangle.
1156 """
1157 face_ind = -1
1158 for i in nb.prange(connectivity_list.shape[0]):
1159 triangle = vertices[connectivity_list[i, :], :]
1160 if in_triangle(triangle, point):
1161 face_ind = i
1162 break
1163 return face_ind
1166@nb.njit('Tuple((f8[:], intp[:]))(f8[:], f8[:])', nogil=True)
1167def _nearest_neighbour_1d(x, x_new):
1168 """
1169 Nearest neighbour interpolation with extrapolation.
1171 This was adapted from scipy.interpolate.interp1d but returns the indices of each nearest x
1172 value. Assumes x is not sorted.
1174 Parameters
1175 ----------
1176 x : (N,) array_like
1177 A 1-D array of real values.
1178 x_new : (N,) array_like
1179 A 1D array of values to apply function to.
1181 Returns
1182 -------
1183 numpy.array
1184 A 1D array of interpolated values.
1185 numpy.array
1186 A 1D array of indices.
1187 """
1188 SIDE = 'left' # use 'right' to round up to nearest int instead of rounding down
1189 # Sort values
1190 ind = np.argsort(x, kind='mergesort')
1191 x = x[ind]
1192 x_bds = x / 2.0 # Do division before addition to prevent possible integer overflow
1193 x_bds = x_bds[1:] + x_bds[:-1]
1194 # Find where in the averaged data the values to interpolate would be inserted.
1195 x_new_indices = np.searchsorted(x_bds, x_new, side=SIDE)
1196 # Clip x_new_indices so that they are within the range of x indices.
1197 x_new_indices = x_new_indices.clip(0, len(x) - 1).astype(np.intp)
1198 # Calculate the actual value for each entry in x_new.
1199 y_new = x[x_new_indices]
1200 return y_new, ind[x_new_indices]
1203@nb.njit('Tuple((f8[:,:], u2[:]))(f8[:], f8[:], f8[:,:], f8[:], f8[:], f8[:], u2[:,:,:])', nogil=True)
1204def _update_points(t, normal_vector, coords, axis_ml_um, axis_ap_um, axis_dv_um, atlas_labels):
1205 """
1206 Determine the MLAPDV coordinate and brain location index for each of the given coordinates.
1208 This has been optimized in numba. The majority of the time savings come from replacing iterp1d
1209 and ismember with _nearest_neighbour_1d which were extremely slow. Parallel iteration further
1210 halved the time it took per 512x512 FOV.
1212 Parameters
1213 ----------
1214 t : numpy.array
1215 An N x 3 evenly spaced set of coordinates representing points going down from the coverslip
1216 towards the brain.
1217 normal_vector : numpy.array
1218 The unit vector of the face normal to the center of the window.
1219 coords : numpy.array
1220 A set of N x 3 coordinates representing the MLAPDV coordinates of each pixel relative to
1221 the center of the window, in micrometers (um).
1222 axis_ml_um : numpy.array
1223 An evenly spaced array of medio-lateral brain coordinates relative to bregma in um, at the
1224 resolution of the atlas image used.
1225 axis_ap_um : numpy.array
1226 An evenly spaced array of anterio-posterior brain coordinates relative to bregma in um, at
1227 the resolution of the atlas image used.
1228 axis_dv_um : numpy.array
1229 An evenly spaced array of dorso-ventral brain coordinates relative to bregma in um, at
1230 the resolution of the atlas image used.
1231 atlas_labels : numpy.array
1232 A 3D array of integers representing the brain location index of each voxel of a given
1233 atlas. The shape is expected to be (nAP, nML, nDV).
1235 Returns
1236 -------
1237 numpy.array
1238 An N by 3 array containing the MLAPDV coordinates in um of each pixel coordinate.
1239 Coordinates outside of the brain are NaN.
1240 numpy.array
1241 A 1D array of atlas label indices the length of `coordinates`.
1242 """
1243 # passing through the center of the craniotomy/coverslip
1244 traj_coords_centered = np.outer(t, -normal_vector)
1245 MLAPDV = np.full_like(coords, np.nan)
1246 annotation = np.zeros(coords.shape[0], dtype=np.uint16)
1247 n_points = coords.shape[0]
1248 for p in nb.prange(n_points):
1249 # Shifted to the correct point on the coverslip, in true ML-AP-DV coords
1250 traj_coords = traj_coords_centered + coords[p, :]
1252 # Find intersection coordinate with the brain.
1253 # Only use coordinates that exist in the atlas (kind of nearest neighbour interpolation)
1254 ml, ml_idx = _nearest_neighbour_1d(axis_ml_um, traj_coords[:, 0])
1255 ap, ap_idx = _nearest_neighbour_1d(axis_ap_um, traj_coords[:, 1])
1256 dv, dv_idx = _nearest_neighbour_1d(axis_dv_um, traj_coords[:, 2])
1258 # Iterate over coordinates to find the first (if any) that is within the brain
1259 ind = -1
1260 area = 0 # 0 = void; 1 = root
1261 for i in nb.prange(traj_coords.shape[0]):
1262 anno = atlas_labels[ap_idx[i], ml_idx[i], dv_idx[i]]
1263 if anno > 0: # first coordinate in the brain
1264 ind = i
1265 area = anno
1266 if area > 1: # non-root brain area; we're done
1267 break
1268 if area > 1:
1269 point = traj_coords[ind, :]
1270 MLAPDV[p, :] = point # in um
1271 annotation[p] = area
1272 else:
1273 MLAPDV[p, :] = np.nan
1274 annotation[p] = area # root or void
1276 return MLAPDV, annotation