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

1"""The mesoscope data extraction pipeline. 

2 

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. 

6 

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 

25 

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 

36 

37from ibllib.pipes import base_tasks 

38from ibllib.io.extractors import mesoscope 

39from iblatlas.atlas import ALLEN_CCF_LANDMARKS_MLAPDV_UM, MRITorontoAtlas 

40 

41 

42_logger = logging.getLogger(__name__) 

43Provenance = enum.Enum('Provenance', ['ESTIMATE', 'FUNCTIONAL', 'LANDMARK', 'HISTOLOGY']) # py3.11 make StrEnum 

44 

45 

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' 

50 

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

62 

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_*')) 

67 

68 def _run(self): 

69 """ 

70 Assert one reference image per collection and rename it. Register snapshots. 

71 

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

85 

86 

87class MesoscopeCompress(base_tasks.MesoscopeTask): 

88 """ Tar compress raw 2p tif files, optionally remove uncompressed data.""" 

89 

90 priority = 90 

91 job_size = 'large' 

92 _log_level = None 

93 

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

101 

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

107 

108 def tearDown(self): 

109 _logger.setLevel(self._log_level or logging.INFO) 1e

110 return super().tearDown() 1e

111 

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. 

115 

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. 

123 

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

133 

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 

144 

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 ) 

148 

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

158 

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) 

169 

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

185 

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

190 

191 return outfiles 1e

192 

193 

194class MesoscopePreprocess(base_tasks.MesoscopeTask): 

195 """Run suite2p preprocessing on tif files""" 

196 

197 priority = 80 

198 cpu = -1 

199 job_size = 'large' 

200 

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

224 

225 @staticmethod 

226 def _masks2sparse(stat, ops): 

227 """ 

228 Extract 3D sparse mask arrays from suit2p output. 

229 

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'). 

236 

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. 

243 

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

262 

263 def _rename_outputs(self, suite2p_dir, frameQC_names, frameQC, rename_dict=None): 

264 """ 

265 Convert suite2p output files to ALF datasets. 

266 

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. 

273 

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

315 

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

320 

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

348 

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. 

353 

354 Parameters 

355 ---------- 

356 meta_data_all: list of dicts 

357 List of metadata dictionaries to be checked for consistency. 

358 

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')} 

367 

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) 

372 

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] 

383 

384 @staticmethod 

385 def _consolidate_exptQC(exptQC): 

386 """ 

387 Consolidate exptQC.mat files into a single file. 

388 

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'}. 

394 

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 """ 

406 

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}"') 

416 

417 frameQC_names = pd.DataFrame(sorted([(v, k) for k, v in frameQC_names.items()]), 

418 columns=['qc_values', 'qc_labels']) 

419 

420 # Concatenate frames 

421 frameQC = np.concatenate([e['frameQC_frames'] for e in exptQC], axis=0) 

422 

423 # Transform to bad_frames as expected by suite2p 

424 bad_frames = np.where(frameQC != 0)[0] 

425 

426 return frameQC, frameQC_names, bad_frames 

427 

428 def get_default_tau(self): 

429 """ 

430 Determine the tau (fluorescence decay) from the subject's genotype. 

431 

432 Returns 

433 ------- 

434 float 

435 The tau value to use. 

436 

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

448 

449 def _create_db(self, meta): 

450 """ 

451 Create the ops dictionary for suite2p based on metadata. 

452 

453 Parameters 

454 ---------- 

455 meta: dict 

456 Imaging metadata. 

457 

458 Returns 

459 ------- 

460 dict 

461 Inputs to suite2p run that deviate from default parameters. 

462 """ 

463 

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') 

468 

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

482 

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 } 

514 

515 return db 1f

516 

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. 

520 

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. 

530 

531 Returns 

532 ------- 

533 list of pathlib.Path 

534 All files created by the task. 

535 """ 

536 import suite2p 1f

537 

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

560 

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) 

575 

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) 

586 

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

596 

597 

598class MesoscopeSync(base_tasks.MesoscopeTask): 

599 """Extract the frame times from the main DAQ.""" 

600 

601 priority = 40 

602 job_size = 'small' 

603 

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

617 

618 def _run(self): 

619 """ 

620 Extract the imaging times for all FOVs. 

621 

622 Returns 

623 ------- 

624 list of pathlib.Path 

625 Files containing frame timestamps for individual FOVs and time offsets for each line scan. 

626 

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

648 

649 

650class MesoscopeFOV(base_tasks.MesoscopeTask): 

651 """Create FOV and FOV location objects in Alyx from metadata.""" 

652 

653 priority = 40 

654 job_size = 'small' 

655 

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

668 

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. 

672 

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. 

678 

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. 

684 

685 Returns 

686 ------- 

687 dict 

688 The newly created FOV Alyx record. 

689 list 

690 The newly created FOV location Alyx records. 

691 

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

702 

703 suffix = None if provenance is Provenance.HISTOLOGY else provenance.name.lower() 1g

704 _logger.info('Extracting %s MLAPDV datasets', suffix or 'final') 1g

705 

706 # Extract mean image MLAPDV coordinates and brain location IDs 

707 mean_image_mlapdv, mean_image_ids = self.project_mlapdv(meta) 1g

708 

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

712 

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

722 

723 # Extract ROI MLAPDV coordinates and brain location IDs 

724 roi_mlapdv, roi_brain_ids = self.roi_mlapdv(nFOV, suffix=suffix) 1g

725 

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

735 

736 # Register FOVs in Alyx 

737 self.register_fov(meta, suffix) 1g

738 

739 return sorted([meta_file, *roi_files, *mean_image_files]) 1g

740 

741 def roi_mlapdv(self, nFOV: int, suffix=None): 

742 """ 

743 Extract ROI MLAPDV coordinates and brain location IDs. 

744 

745 MLAPDV coordinates are in um relative to bregma. Location IDs are from the 2017 Allen 

746 common coordinate framework atlas. 

747 

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'. 

755 

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

767 

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

773 

774 # Load MLAPDV + brain location ID maps of pixels 

775 mpciMeanImage = alfio.load_object( 1g

776 alf_path, 'mpciMeanImage', attribute=['mlapdv', 'brainLocationIds']) 

777 

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

789 

790 return all_mlapdv, all_brain_ids 1g

791 

792 @staticmethod 

793 def get_provenance(filename): 

794 """ 

795 Get the field of view provenance from a mpciMeanImage or mpciROIs dataset. 

796 

797 Parameters 

798 ---------- 

799 filename : str, pathlib.Path 

800 A filename to get the provenance from. 

801 

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

812 

813 def register_fov(self, meta: dict, suffix: str = None) -> (list, list): 

814 """ 

815 Create FOV on Alyx. 

816 

817 Assumes field of view recorded perpendicular to objective. 

818 Assumes field of view is plane (negligible volume). 

819 

820 Required Alyx fixtures: 

821 - experiments.ImagingType(name='mesoscope') 

822 - experiments.CoordinateSystem(name='IBL-Allen') 

823 

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). 

831 

832 Returns 

833 ------- 

834 list of dict 

835 A list registered of field of view entries from Alyx. 

836 

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} 

847 

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

862 

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

870 

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

876 

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

881 

882 data['brain_region'] = np.unique(mean_image_ids).astype(int).tolist() 1h

883 

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

890 

891 def load_triangulation(self): 

892 """ 

893 Load the surface triangulation file. 

894 

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. 

898 

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

913 

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. 

918 

919 MLAPDV coordinates are in um relative to bregma. Location IDs are from the 2017 Allen 

920 common coordinate framework atlas. 

921 

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. 

929 

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

946 

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

959 

960 dorsal_triangle = points[dorsal_connectivity_list[face_ind, :], :] 1b

961 

962 # Get the surface normal unit vector of dorsal triangle 

963 normal_vector = surface_normal(dorsal_triangle) 1b

964 

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

968 

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

972 

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

976 

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) 

980 

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

989 

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

998 

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

1005 

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

1022 

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

1027 

1028 xx = xx.flatten() - coord_ml 1b

1029 yy = yy.flatten() - coord_ap 1b

1030 

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

1035 

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

1039 

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

1044 

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

1050 

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 } 

1058 

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 } 

1067 

1068 mlapdv[i] = MLAPDV 1b

1069 location_id[i] = annotation 1b

1070 return mlapdv, location_id 1b

1071 

1072 

1073def surface_normal(triangle): 

1074 """ 

1075 Calculate the surface normal unit vector of one or more triangles. 

1076 

1077 Parameters 

1078 ---------- 

1079 triangle : numpy.array 

1080 An array of shape (n_triangles, 3, 3) representing (Px Py Pz). 

1081 

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

1093 

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

1101 

1102 

1103@nb.njit('b1(f8[:,:], f8[:])') 

1104def in_triangle(triangle, point): 

1105 """ 

1106 Check whether `point` lies within `triangle`. 

1107 

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). 

1114 

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.) 

1123 

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 

1134 

1135 

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. 

1140 

1141 Currently O(n) but could take advantage of connectivity order to be quicker. 

1142 

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`. 

1151 

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 

1164 

1165 

1166@nb.njit('Tuple((f8[:], intp[:]))(f8[:], f8[:])', nogil=True) 

1167def _nearest_neighbour_1d(x, x_new): 

1168 """ 

1169 Nearest neighbour interpolation with extrapolation. 

1170 

1171 This was adapted from scipy.interpolate.interp1d but returns the indices of each nearest x 

1172 value. Assumes x is not sorted. 

1173 

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. 

1180 

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] 

1201 

1202 

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. 

1207 

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. 

1211 

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). 

1234 

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, :] 

1251 

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]) 

1257 

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 

1275 

1276 return MLAPDV, annotation