• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

int-brain-lab / ibllib / 1761696499260742

05 Oct 2023 09:46AM UTC coverage: 55.27% (-1.4%) from 56.628%
1761696499260742

Pull #655

continuous-integration/UCL

bimac
add @sleepless decorator
Pull Request #655: add @sleepless decorator

21 of 21 new or added lines in 1 file covered. (100.0%)

10330 of 18690 relevant lines covered (55.27%)

0.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.38
/ibllib/pipes/mesoscope_tasks.py
1
"""The mesoscope data extraction pipeline.
1✔
2

3
The mesoscope pipeline currently comprises raw image registration and timestamps extraction.  In
4
the future there will be compression (and potential cropping), FOV metadata extraction, and ROI
5
extraction.
6

7
Pipeline:
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
"""
14
import json
1✔
15
import logging
1✔
16
import subprocess
1✔
17
import shutil
1✔
18
from pathlib import Path
1✔
19
from itertools import chain
1✔
20
from collections import defaultdict, Counter
1✔
21
from fnmatch import fnmatch
1✔
22
import enum
1✔
23
import re
1✔
24
import time
1✔
25

26
import numba as nb
1✔
27
import numpy as np
1✔
28
import pandas as pd
1✔
29
import sparse
1✔
30
from scipy.io import loadmat
1✔
31
from scipy.interpolate import interpn
1✔
32
import one.alf.io as alfio
1✔
33
from one.alf.spec import is_valid, to_alf
1✔
34
from one.alf.files import filename_parts, session_path_parts
1✔
35
import one.alf.exceptions as alferr
1✔
36

37
from ibllib.pipes import base_tasks
1✔
38
from ibllib.io.extractors import mesoscope
1✔
39
from iblatlas.atlas import ALLEN_CCF_LANDMARKS_MLAPDV_UM, MRITorontoAtlas
1✔
40

41

42
_logger = logging.getLogger(__name__)
1✔
43
Provenance = enum.Enum('Provenance', ['ESTIMATE', 'FUNCTIONAL', 'LANDMARK', 'HISTOLOGY'])  # py3.11 make StrEnum
1✔
44

45

46
class MesoscopeRegisterSnapshots(base_tasks.MesoscopeTask, base_tasks.RegisterRawDataTask):
1✔
47
    """Upload snapshots as Alyx notes and register the 2P reference image(s)."""
1✔
48
    priority = 100
1✔
49
    job_size = 'small'
1✔
50

51
    @property
1✔
52
    def signature(self):
1✔
53
        signature = {
1✔
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
1✔
62

63
    def __init__(self, session_path, **kwargs):
1✔
64
        super().__init__(session_path, **kwargs)
1✔
65
        self.device_collection = self.get_device_collection('mesoscope',
1✔
66
                                                            kwargs.get('device_collection', 'raw_imaging_data_*'))
67

68
    def _run(self):
1✔
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]
×
78
        reference_images = list(self.session_path.rglob(f'{collection}/{file}'))
×
79
        assert len(set(x.parent for x in reference_images)) == len(reference_images)
×
80
        # Rename the reference images
81
        out_files = super()._run()
×
82
        # Register snapshots in base session folder and raw_imaging_data folders
83
        self.register_snapshots(collection=[self.device_collection, ''])
×
84
        return out_files
×
85

86

87
class MesoscopeCompress(base_tasks.MesoscopeTask):
1✔
88
    """ Tar compress raw 2p tif files, optionally remove uncompressed data."""
1✔
89

90
    priority = 90
1✔
91
    job_size = 'large'
1✔
92
    _log_level = None
1✔
93

94
    @property
1✔
95
    def signature(self):
1✔
96
        signature = {
1✔
97
            'input_files': [('*.tif', self.device_collection, True)],
98
            'output_files': [('imaging.frames.tar.bz2', self.device_collection, True)]
99
        }
100
        return signature
1✔
101

102
    def setUp(self, **kwargs):
1✔
103
        """Run at higher log level"""
104
        self._log_level = _logger.level
1✔
105
        _logger.setLevel(logging.DEBUG)
1✔
106
        return super().setUp(**kwargs)
1✔
107

108
    def tearDown(self):
1✔
109
        _logger.setLevel(self._log_level or logging.INFO)
1✔
110
        return super().tearDown()
1✔
111

112
    def _run(self, remove_uncompressed=False, verify_output=True, clobber=False, **kwargs):
1✔
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
1✔
130
        input_files = defaultdict(list)
1✔
131
        for file, in_dir, _ in self.input_files:
1✔
132
            input_files[self.session_path.joinpath(in_dir)].append(file)
1✔
133

134
        for in_dir, files in input_files.items():
1✔
135
            outfile = in_dir / self.output_files[0][0]
1✔
136
            if outfile.exists() and not clobber:
1✔
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)))
1✔
141
            if not infiles:
1✔
142
                _logger.info('No image files found in %s', in_dir.relative_to(self.session_path))
×
143
                continue
×
144

145
            _logger.debug(
1✔
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)
1✔
150
            _logger.info('Compressing %i file(s)', len(infiles))
1✔
151
            cmd = 'tar -cjvf "{output}" "{input}"'.format(
1✔
152
                output=outfile.relative_to(in_dir), input='" "'.join(str(x.relative_to(in_dir)) for x in infiles))
153
            _logger.debug(cmd)
1✔
154
            process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir)
1✔
155
            info, error = process.communicate()  # b'2023-02-17_2_test_2P_00001_00001.tif\n'
1✔
156
            _logger.debug(info.decode())
1✔
157
            assert process.returncode == 0, f'compression failed: {error.decode()}'
1✔
158

159
            # Check the output
160
            assert outfile.exists(), 'output file missing'
1✔
161
            outfiles.append(outfile)
1✔
162
            compressed_size = outfile.stat().st_size
1✔
163
            min_size = kwargs.pop('verify_min_size', 1024)
1✔
164
            assert compressed_size > int(min_size), f'Compressed file < {min_size / 1024:.0f}KB'
1✔
165
            _logger.info('Compression ratio = %.3f, saving %.2f pct (%.2f MB)',
1✔
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:
1✔
171
                # Test bzip
172
                cmd = f'bzip2 -tv {outfile.relative_to(in_dir)}'
1✔
173
                process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir)
1✔
174
                info, error = process.communicate()
1✔
175
                _logger.debug(info.decode())
1✔
176
                assert process.returncode == 0, f'bzip compression test failed: {error}'
1✔
177
                # Check tar
178
                cmd = f'bunzip2 -dc {outfile.relative_to(in_dir)} | tar -tvf -'
1✔
179
                process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=in_dir)
1✔
180
                info, error = process.communicate()
1✔
181
                _logger.debug(info.decode())
1✔
182
                assert process.returncode == 0, 'tarball decompression test failed'
1✔
183
                compressed_files = set(x.split()[-1] for x in filter(None, info.decode().split('\n')))
1✔
184
                assert compressed_files == set(x.name for x in infiles)
1✔
185

186
            if remove_uncompressed:
1✔
187
                _logger.info(f'Removing input files for {in_dir.relative_to(self.session_path)}')
1✔
188
                for file in infiles:
1✔
189
                    file.unlink()
1✔
190

191
        return outfiles
1✔
192

193

194
class MesoscopePreprocess(base_tasks.MesoscopeTask):
1✔
195
    """Run suite2p preprocessing on tif files"""
1✔
196

197
    priority = 80
1✔
198
    cpu = -1
1✔
199
    job_size = 'large'
1✔
200

201
    @property
1✔
202
    def signature(self):
1✔
203
        # The number of in and outputs will be dependent on the number of input raw imaging folders and output FOVs
204
        signature = {
1✔
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
1✔
224

225
    @staticmethod
1✔
226
    def _masks2sparse(stat, ops):
1✔
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'])
1✔
249
        npx = np.prod(shape[1:])  # Number of pixels per time point
1✔
250
        coords = [[], [], []]
1✔
251
        data = []
1✔
252
        pil_coords = []
1✔
253
        for i, s in enumerate(stat):
1✔
254
            coords[0].append(np.full(s['ypix'].shape, i))
1✔
255
            coords[1].append(s['ypix'])
1✔
256
            coords[2].append(s['xpix'])
1✔
257
            data.append(s['lam'])
1✔
258
            pil_coords.append(s['neuropil_mask'] + i * npx)
1✔
259
        roi_mask_sp = sparse.COO(list(map(np.concatenate, coords)), np.concatenate(data), shape=shape)
1✔
260
        pil_mask_sp = sparse.COO(np.unravel_index(np.concatenate(pil_coords), shape), True, shape=shape)
1✔
261
        return sparse.GCXS.from_coo(roi_mask_sp), sparse.GCXS.from_coo(pil_mask_sp)
1✔
262

263
    def _rename_outputs(self, suite2p_dir, frameQC_names, frameQC, rename_dict=None):
1✔
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:
1✔
280
            rename_dict = {
1✔
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():
1✔
287
            # ignore the combined dir
288
            if plane_dir.name != 'combined':
1✔
289
                n = int(plane_dir.name.split('plane')[1])
1✔
290
                fov_dir = plane_dir.parent.joinpath(f'FOV_{n:02}')
1✔
291
                if fov_dir.exists():
1✔
292
                    shutil.rmtree(str(fov_dir), ignore_errors=False, onerror=None)
×
293
                plane_dir.rename(fov_dir)
1✔
294
        # Now rename the content of the new directories and move them out of suite2p
295
        for fov_dir in suite2p_dir.iterdir():
1✔
296
            # Compress suite2p output files
297
            target = suite2p_dir.parent.joinpath(fov_dir.name)
1✔
298
            target.mkdir(exist_ok=True)
1✔
299
            # Move bin file out of the way first
300
            if fov_dir.joinpath('data.bin').exists():
1✔
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
1✔
307
            _logger.setLevel(logging.WARNING)
1✔
308
            shutil.make_archive(str(target / '_suite2p_ROIData.raw'), 'zip', fov_dir, logger=_logger)
1✔
309
            _logger.setLevel(prev_level)
1✔
310
            if fov_dir != 'combined':
1✔
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:
1✔
313
                    np.save(fov_dir.joinpath('mpci.mpciFrameQC.npy'), frameQC)
1✔
314
                    frameQC_names.to_csv(fov_dir.joinpath('mpciFrameQC.names.tsv'), sep='\t', index=False)
1✔
315

316
                # extract some other data from suite2p outputs
317
                ops = np.load(fov_dir.joinpath('ops.npy'), allow_pickle=True).item()
1✔
318
                stat = np.load(fov_dir.joinpath('stat.npy'), allow_pickle=True)
1✔
319
                iscell = np.load(fov_dir.joinpath('iscell.npy'))
1✔
320

321
                # Save suite2p ROI activity outputs in transposed from (n_frames, n_ROI)
322
                for k, v in rename_dict.items():
1✔
323
                    np.save(fov_dir.joinpath(v), np.load(fov_dir.joinpath(k)).T)
1✔
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))
1✔
326
                np.save(fov_dir.joinpath('mpciMeanImage.images.npy'), np.asarray(ops['meanImg'], dtype=float))
1✔
327
                np.save(fov_dir.joinpath('mpciROIs.stackPos.npy'), np.asarray([(*s['med'], 0) for s in stat], dtype=int))
1✔
328
                np.save(fov_dir.joinpath('mpciROIs.cellClassifier.npy'), np.asarray(iscell[:, 1], dtype=float))
1✔
329
                np.save(fov_dir.joinpath('mpciROIs.mpciROITypes.npy'), np.asarray(iscell[:, 0], dtype=np.int16))
1✔
330
                pd.DataFrame([(0, 'no cell'), (1, 'cell')], columns=['roi_values', 'roi_labels']
1✔
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)
1✔
334
                with open(fov_dir.joinpath('mpciROIs.masks.sparse_npz'), 'wb') as fp:
1✔
335
                    sparse.save_npz(fp, roi_mask)
1✔
336
                with open(fov_dir.joinpath('mpciROIs.neuropilMasks.sparse_npz'), 'wb') as fp:
1✔
337
                    sparse.save_npz(fp, pil_mask)
1✔
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()):
1✔
341
                    target_file = target.joinpath(file.name)
1✔
342
                    if target_file.exists():
1✔
343
                        target_file.unlink()
×
344
                    file.rename(target_file)
1✔
345
        shutil.rmtree(str(suite2p_dir), ignore_errors=False, onerror=None)
1✔
346
        # Collect all files in those directories
347
        return list(suite2p_dir.parent.rglob('FOV*/*'))
1✔
348

349
    @staticmethod
1✔
350
    def _check_meta_data(meta_data_all: list) -> dict:
1✔
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
1✔
385
    def _consolidate_exptQC(exptQC):
1✔
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):
1✔
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}
1✔
443
        _, subject, *_ = session_path_parts(self.session_path)
1✔
444
        genotype = self.one.alyx.rest('subjects', 'read', id=subject)['genotype']
1✔
445
        match = next(filter(None, (re.match(r'.+-(G\d[fms])$', g['allele']) for g in genotype)), None)
1✔
446
        key = match.groups()[0] if match else 'default'
1✔
447
        return TAU_MAP.get(key, TAU_MAP['default'])
1✔
448

449
    def _create_db(self, meta):
1✔
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):
1✔
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']])
1✔
471
        cXY -= np.min(cXY, axis=0)
1✔
472
        nXnYnZ = np.array([fov['nXnYnZ'] for fov in meta['FOV']])
1✔
473
        sW = np.sqrt(np.sum((np.array([fov['topRightDeg'] for fov in meta['FOV']]) - np.array(
1✔
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(
1✔
476
            [fov['topLeftDeg'] for fov in meta['FOV']])) ** 2, axis=1))
477
        pixSizeX = nXnYnZ[:, 0] / sW
1✔
478
        pixSizeY = nXnYnZ[:, 1] / sH
1✔
479
        dx = np.round(cXY[:, 0] * pixSizeX).astype(dtype=np.int32)
1✔
480
        dy = np.round(cXY[:, 1] * pixSizeY).astype(dtype=np.int32)
1✔
481
        nchannels = len(meta['channelSaved']) if isinstance(meta['channelSaved'], list) else 1
1✔
482

483
        db = {
1✔
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
1✔
516

517
    def _run(self, run_suite2p=True, rename_files=True, use_badframes=True, **kwargs):
1✔
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
1✔
537

538
        """ Metadata and parameters """
1✔
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.*'))
1✔
541
        collections = set(f.parts[-2] for f in meta_files)
1✔
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)
1✔
544
        rawImagingData = [mesoscope.patch_imaging_meta(alfio.load_file_content(filepath)) for filepath in meta_files]
1✔
545
        if len(rawImagingData) > 1:
1✔
546
            meta = self._check_meta_data(rawImagingData)
×
547
        else:
548
            meta = rawImagingData[0]
1✔
549
        # Get default ops
550
        ops = suite2p.default_ops()
1✔
551
        # Create db which overwrites ops when passed to suite2p, with information from meta data and hardcoded
552
        db = self._create_db(meta)
1✔
553
        # Anything can be overwritten by keyword arguments passed to the tasks run() method
554
        for k, v in kwargs.items():
1✔
555
            if k in ops.keys() or k in db.keys():
1✔
556
                # db overwrites ops when passed to run_s2p, so we only need to update / add it here
557
                db[k] = v
1✔
558
        # Update the task kwargs attribute as it will be stored in the arguments json field in alyx
559
        self.kwargs = {**self.kwargs, **db}
1✔
560

561
        """ Bad frames """
1✔
562
        qc_paths = (self.session_path.joinpath(f[1], 'exptQC.mat')
1✔
563
                    for f in self.input_files if f[0] == 'exptQC.mat')
564
        qc_paths = map(str, filter(Path.exists, qc_paths))
1✔
565
        exptQC = [loadmat(p, squeeze_me=True, simplify_cells=True) for p in qc_paths]
1✔
566
        if len(exptQC) > 0:
1✔
567
            frameQC, frameQC_names, bad_frames = self._consolidate_exptQC(exptQC)
×
568
        else:
569
            _logger.warning('No frame QC (exptQC.mat) files found.')
1✔
570
            frameQC, bad_frames = np.array([], dtype='u1'), np.array([], dtype='i8')
1✔
571
            frameQC_names = pd.DataFrame(columns=['qc_values', 'qc_labels'])
1✔
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:
1✔
574
            np.save(Path(db['data_path'][0]).joinpath('bad_frames.npy'), bad_frames)
×
575

576
        """ Suite2p """
1✔
577
        # Create alf it is doesn't exist
578
        self.session_path.joinpath('alf').mkdir(exist_ok=True)
1✔
579
        # Remove existing suite2p dir if it exists
580
        suite2p_dir = Path(db['save_path0']).joinpath('suite2p')
1✔
581
        if suite2p_dir.exists():
1✔
582
            shutil.rmtree(str(suite2p_dir), ignore_errors=True, onerror=None)
×
583
        # Run suite2p
584
        if run_suite2p:
1✔
585
            _ = suite2p.run_s2p(ops=ops, db=db)
×
586

587
        """ Outputs """
1✔
588
        # Save and rename other outputs
589
        if rename_files:
1✔
590
            out_files = self._rename_outputs(suite2p_dir, frameQC_names, frameQC)
×
591
        else:
592
            out_files = list(Path(db['save_path0']).joinpath('suite2p').rglob('*'))
1✔
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]]
1✔
595
        return out_files
1✔
596

597

598
class MesoscopeSync(base_tasks.MesoscopeTask):
1✔
599
    """Extract the frame times from the main DAQ."""
1✔
600

601
    priority = 40
1✔
602
    job_size = 'small'
1✔
603

604
    @property
1✔
605
    def signature(self):
1✔
606
        signature = {
1✔
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
1✔
617

618
    def _run(self):
1✔
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:
1✔
630
            alf_path = self.session_path / self.sync_collection
1✔
631
            events = alfio.load_object(alf_path, 'softwareEvents').get('log')
1✔
632
        except alferr.ALFObjectNotFound:
1✔
633
            events = None
1✔
634
        if events is None or events.empty:
1✔
635
            _logger.debug('No software events found for session %s', self.session_path)
1✔
636
        collections = set(collection for _, collection, _ in self.input_files
1✔
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')
1✔
641
        self.rawImagingData['meta'] = mesoscope.patch_imaging_meta(self.rawImagingData['meta'])
1✔
642
        n_FOVs = len(self.rawImagingData['meta']['FOV'])
1✔
643
        sync, chmap = self.load_sync()  # Extract sync data from raw DAQ data
1✔
644
        mesosync = mesoscope.MesoscopeSyncTimeline(self.session_path, n_FOVs)
1✔
645
        _, out_files = mesosync.extract(
1✔
646
            save=True, sync=sync, chmap=chmap, device_collection=collections, events=events)
647
        return out_files
1✔
648

649

650
class MesoscopeFOV(base_tasks.MesoscopeTask):
1✔
651
    """Create FOV and FOV location objects in Alyx from metadata."""
1✔
652

653
    priority = 40
1✔
654
    job_size = 'small'
1✔
655

656
    @property
1✔
657
    def signature(self):
1✔
658
        signature = {
1✔
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
1✔
668

669
    def _run(self, *args, provenance=Provenance.ESTIMATE, **kwargs):
1✔
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']
1✔
699
        meta_file = next(self.session_path.glob(f'{collection}/{filename}'), None)
1✔
700
        meta = alfio.load_file_content(meta_file) or {}
1✔
701
        nFOV = len(meta.get('FOV', []))
1✔
702

703
        suffix = None if provenance is Provenance.HISTOLOGY else provenance.name.lower()
1✔
704
        _logger.info('Extracting %s MLAPDV datasets', suffix or 'final')
1✔
705

706
        # Extract mean image MLAPDV coordinates and brain location IDs
707
        mean_image_mlapdv, mean_image_ids = self.project_mlapdv(meta)
1✔
708

709
        # Save the meta data file with new coordinate fields
710
        with open(meta_file, 'w') as fp:
1✔
711
            json.dump(meta, fp)
1✔
712

713
        # Save the mean image datasets
714
        mean_image_files = []
1✔
715
        assert set(mean_image_mlapdv.keys()) == set(mean_image_ids.keys()) and len(mean_image_ids) == nFOV
1✔
716
        for i in range(nFOV):
1✔
717
            alf_path = self.session_path.joinpath('alf', f'FOV_{i:02}')
1✔
718
            for attr, arr, sfx in (('mlapdv', mean_image_mlapdv[i], suffix),
1✔
719
                                   ('brainLocationIds', mean_image_ids[i], ('ccf', '2017', suffix))):
720
                mean_image_files.append(alf_path / to_alf('mpciMeanImage', attr, 'npy', timescale=sfx))
1✔
721
                np.save(mean_image_files[-1], arr)
1✔
722

723
        # Extract ROI MLAPDV coordinates and brain location IDs
724
        roi_mlapdv, roi_brain_ids = self.roi_mlapdv(nFOV, suffix=suffix)
1✔
725

726
        # Write MLAPDV + brain location ID of ROIs to disk
727
        roi_files = []
1✔
728
        assert set(roi_mlapdv.keys()) == set(roi_brain_ids.keys()) and len(roi_mlapdv) == nFOV
1✔
729
        for i in range(nFOV):
1✔
730
            alf_path = self.session_path.joinpath('alf', f'FOV_{i:02}')
1✔
731
            for attr, arr, sfx in (('mlapdv', roi_mlapdv[i], suffix),
1✔
732
                                   ('brainLocationIds', roi_brain_ids[i], ('ccf', '2017', suffix))):
733
                roi_files.append(alf_path / to_alf('mpciROIs', attr, 'npy', timescale=sfx))
1✔
734
                np.save(roi_files[-1], arr)
1✔
735

736
        # Register FOVs in Alyx
737
        self.register_fov(meta, suffix)
1✔
738

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

741
    def roi_mlapdv(self, nFOV: int, suffix=None):
1✔
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 = {}
1✔
764
        all_brain_ids = {}
1✔
765
        for n in range(nFOV):
1✔
766
            alf_path = self.session_path.joinpath('alf', f'FOV_{n:02}')
1✔
767

768
            # Load neuron centroids in pixel space
769
            stack_pos_file = next(alf_path.glob('mpciROIs.stackPos*'), None)
1✔
770
            if not stack_pos_file:
1✔
771
                raise FileNotFoundError(alf_path / 'mpci.stackPos*')
1✔
772
            stack_pos = alfio.load_file_content(stack_pos_file)
1✔
773

774
            # Load MLAPDV + brain location ID maps of pixels
775
            mpciMeanImage = alfio.load_object(
1✔
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)
1✔
780
            brain_ids = np.full(stack_pos.shape[0], np.nan)
1✔
781
            for i in np.arange(stack_pos.shape[0]):
1✔
782
                idx = (stack_pos[i, 0], stack_pos[i, 1])
1✔
783
                sfx = f'_{suffix}' if suffix else ''
1✔
784
                mlapdv[i, :] = mpciMeanImage['mlapdv' + sfx][idx]
1✔
785
                brain_ids[i] = mpciMeanImage['brainLocationIds_ccf_2017' + sfx][idx]
1✔
786
            assert ~np.isnan(brain_ids).any()
1✔
787
            all_brain_ids[n] = brain_ids.astype(int)
1✔
788
            all_mlapdv[n] = mlapdv
1✔
789

790
        return all_mlapdv, all_brain_ids
1✔
791

792
    @staticmethod
1✔
793
    def get_provenance(filename):
1✔
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
1✔
808
        timescale = (filename_parts(filename)[3] or '').split('_')
1✔
809
        provenances = [i.name.lower() for i in Provenance]
1✔
810
        provenance = (Provenance[x.upper()] for x in timescale if x in provenances)
1✔
811
        return next(provenance, None) or Provenance.HISTOLOGY
1✔
812

813
    def register_fov(self, meta: dict, suffix: str = None) -> (list, list):
1✔
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
1✔
840
        alyx_fovs = []
1✔
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', []))
1✔
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']
1✔
846
                     for i in slice_counts if slice_counts[i] > 1}
847

848
        for i, fov in enumerate(meta.get('FOV', [])):
1✔
849
            assert set(fov.keys()) >= {'MLAPDV', 'nXnYnZ', 'roiUUID'}
1✔
850
            # Field of view
851
            alyx_FOV = {
1✔
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:
×
857
                print(alyx_FOV)
×
858
                alyx_FOV['location'] = []
×
859
                alyx_fovs.append(alyx_FOV)
×
860
            else:
861
                alyx_fovs.append(self.one.alyx.rest('fields-of-view', 'create', data=alyx_FOV))
×
862

863
            # Field of view location
864
            data = {'field_of_view': alyx_fovs[-1].get('id'),
×
865
                    'default_provenance': True,
866
                    'coordinate_system': 'IBL-Allen',
867
                    'n_xyz': fov['nXnYnZ']}
868
            if suffix:
×
869
                data['provenance'] = suffix[0].upper()
×
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')]
×
874
            coords = np.vstack(coords).T
×
875
            data.update({k: arr.tolist() for k, arr in zip('xyz', coords)})
×
876

877
            # Load MLAPDV + brain location ID maps of pixels
878
            filename = 'mpciMeanImage.brainLocationIds_ccf_2017' + (f'_{suffix}' if suffix else '') + '.npy'
×
879
            filepath = self.session_path.joinpath('alf', f'FOV_{i:02}', filename)
×
880
            mean_image_ids = alfio.load_file_content(filepath)
×
881

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

884
            if dry:
×
885
                print(data)
×
886
                alyx_FOV['location'].append(data)
×
887
            else:
888
                alyx_fovs[-1]['location'].append(self.one.alyx.rest('fov-location', 'create', data=data))
×
889
        return alyx_fovs
×
890

891
    def load_triangulation(self):
1✔
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')
1✔
908
        surface_triangulation = np.load(fixture_path / 'surface_triangulation.npz')
1✔
909
        points = surface_triangulation['points'].astype('f8')
1✔
910
        connectivity_list = surface_triangulation['connectivity_list']
1✔
911
        surface_triangulation.close()
1✔
912
        return points, connectivity_list
1✔
913

914
    def project_mlapdv(self, meta, atlas=None):
1✔
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 = {}
1✔
938
        location_id = {}
1✔
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)
1✔
942
        # The centre of the craniotomy / imaging window
943
        coord_ml = meta['centerMM']['ML'] * 1e3  # mm -> um
1✔
944
        coord_ap = meta['centerMM']['AP'] * 1e3  # mm -> um
1✔
945
        pt = np.array([coord_ml, coord_ap])
1✔
946

947
        points, connectivity_list = self.load_triangulation()
1✔
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, :]
1✔
951
        normals = surface_normal(triangles)
1✔
952
        up_faces, = np.where(normals[:, -1] > 0)
1✔
953
        # only keep triangles that have normal vector with positive DV component
954
        dorsal_connectivity_list = connectivity_list[up_faces, :]
1✔
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))
1✔
958
        assert face_ind != -1
1✔
959

960
        dorsal_triangle = points[dorsal_connectivity_list[face_ind, :], :]
1✔
961

962
        # Get the surface normal unit vector of dorsal triangle
963
        normal_vector = surface_normal(dorsal_triangle)
1✔
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, :], :]
1✔
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)
1✔
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]
1✔
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
1✔
985
        vX = np.cross(vY, normal_vector)  # orthogonal to n and to vY
1✔
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
1✔
988
        vY = vY / np.sqrt(vY @ vY) * np.sign(vY[1])
1✔
989

990
        # what are the dimensions of the data arrays (ap, ml, dv)
991
        (nAP, nML, nDV) = atlas.image.shape
1✔
992
        # Let's shift the coordinates relative to bregma
993
        voxel_size = atlas.res_um  # [um] resolution of the atlas
1✔
994
        bregma_coords = ALLEN_CCF_LANDMARKS_MLAPDV_UM['bregma'] / voxel_size  # (ml, ap, dv)
1✔
995
        axis_ml_um = (np.arange(nML) - bregma_coords[0]) * voxel_size
1✔
996
        axis_ap_um = (np.arange(nAP) - bregma_coords[1]) * voxel_size * -1.
1✔
997
        axis_dv_um = (np.arange(nDV) - bregma_coords[2]) * voxel_size * -1.
1✔
998

999
        # projection of FOVs on the brain surface to get ML-AP-DV coordinates
1000
        _logger.info('Projecting in 3D')
1✔
1001
        for i, fov in enumerate(meta['FOV']):  # i, fov = next(enumerate(meta['FOV']))
1✔
1002
            start_time = time.time()
1✔
1003
            _logger.info(f'FOV {i + 1}/{len(meta["FOV"])}')
1✔
1004
            y_px_idx, x_px_idx = np.mgrid[0:fov['nXnYnZ'][0], 0:fov['nXnYnZ'][1]]
1✔
1005

1006
            # xx and yy are in mm in coverslip space
1007
            points = ((0, fov['nXnYnZ'][0] - 1), (0, fov['nXnYnZ'][1] - 1))
1✔
1008
            if 'MM' not in fov:
1✔
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]],
1✔
1019
                      [fov['MM']['bottomLeft'][0], fov['MM']['bottomRight'][0]]]
1020
            values = np.array(values) * 1e3  # mm -> um
1✔
1021
            xx = interpn(points, values, (y_px_idx, x_px_idx))
1✔
1022

1023
            values = [[fov['MM']['topLeft'][1], fov['MM']['topRight'][1]],
1✔
1024
                      [fov['MM']['bottomLeft'][1], fov['MM']['bottomRight'][1]]]
1025
            values = np.array(values) * 1e3  # mm -> um
1✔
1026
            yy = interpn(points, values, (y_px_idx, x_px_idx))
1✔
1027

1028
            xx = xx.flatten() - coord_ml
1✔
1029
            yy = yy.flatten() - coord_ap
1✔
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)
1✔
1034
            coords = coords + [coord_ml, coord_ap, coord_dv]
1✔
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)
1✔
1039

1040
            # Find the MLAPDV atlas coordinate and brain location of each pixel.
1041
            MLAPDV, annotation = _update_points(
1✔
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
1✔
1044

1045
            if np.any(np.isnan(MLAPDV)):
1✔
1046
                _logger.warning('Areas of FOV lie outside the brain')
1✔
1047
            _logger.info(f'done ({time.time() - start_time:3.1f} seconds)\n')
1✔
1048
            MLAPDV = np.reshape(MLAPDV, [*x_px_idx.shape, 3])
1✔
1049
            annotation = np.reshape(annotation, x_px_idx.shape)
1✔
1050

1051
            fov['MLAPDV'] = {
1✔
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'] = {
1✔
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
1✔
1069
            location_id[i] = annotation
1✔
1070
        return mlapdv, location_id
1✔
1071

1072

1073
def surface_normal(triangle):
1✔
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):
1✔
1088
        triangle = triangle[np.newaxis, :, :]
1✔
1089
    if triangle.shape[1:] != (3, 3):
1✔
1090
        raise ValueError('expected array of shape (3, 3); 3 coordinates in x, y, and z')
1✔
1091
    V = triangle[:, 1, :] - triangle[:, 0, :]  # V = P2 - P1
1✔
1092
    W = triangle[:, 2, :] - triangle[:, 0, :]  # W = P3 - P1
1✔
1093

1094
    Nx = (V[:, 1] * W[:, 2]) - (V[:, 2] * W[:, 1])  # Nx = (Vy * Wz) - (Vz * Wy)
1✔
1095
    Ny = (V[:, 2] * W[:, 0]) - (V[:, 0] * W[:, 2])  # Ny = (Vz * Wx) - (Vx * Wz)
1✔
1096
    Nz = (V[:, 0] * W[:, 1]) - (V[:, 1] * W[:, 0])  # Nz = (Vx * Wy) - (Vy * Wx)
1✔
1097
    N = np.c_[Nx, Ny, Nz]
1✔
1098
    # Calculate unit vector. Transpose allows vectorized operation.
1099
    A = N / np.sqrt((Nx ** 2) + (Ny ** 2) + (Nz ** 2))[np.newaxis].T
1✔
1100
    return A.squeeze()
1✔
1101

1102

1103
@nb.njit('b1(f8[:,:], f8[:])')
1✔
1104
def in_triangle(triangle, point):
1✔
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)
1✔
1137
def find_triangle(point, vertices, connectivity_list):
1✔
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)
1✔
1167
def _nearest_neighbour_1d(x, x_new):
1✔
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)
1✔
1204
def _update_points(t, normal_vector, coords, axis_ml_um, axis_ap_um, axis_dv_um, atlas_labels):
1✔
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
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc