Coverage for ibllib/qc/alignment_qc.py: 67%
228 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-11 11:13 +0100
1import logging
3import numpy as np
4from pathlib import Path
6from neuropixel import trace_header
7import spikeglx
9from iblatlas.atlas import AllenAtlas
10from ibllib.pipes import histology
11from ibllib.pipes.ephys_alignment import EphysAlignment
12from ibllib.qc import base
13from ibllib.oneibl.patcher import FTPPatcher
14from ibllib.qc.base import CRITERIA as CRITERIA_BASE
16_log = logging.getLogger(__name__)
17CRITERIA = {"PASS": 0.8}
20class AlignmentQC(base.QC):
21 """
22 Class that is used to update the extended_qc of the probe insertion fields with the results
23 from the ephys alignment procedure
24 """
25 def __init__(self, probe_id, one=None, brain_atlas=None, channels=True, collection=None):
26 super().__init__(probe_id, one=one, log=_log, endpoint='insertions') 1ab
28 # Data
29 self.alignments = None 1ab
30 self.xyz_picks = None 1ab
31 self.depths = None 1ab
32 self.cluster_chns = None 1ab
33 self.chn_coords = None 1ab
34 self.align_keys_sorted = None 1ab
36 # Metrics and passed trials
37 self.sim_matrix = None 1ab
38 self.criteria = CRITERIA 1ab
40 # Get the brain atlas
41 self.brain_atlas = brain_atlas or AllenAtlas(25) 1ab
42 # Flag for uploading channels to alyx. For testing purposes
43 self.channels_flag = channels 1ab
45 self.insertion = self.one.alyx.get(f'/insertions/{self.eid}', clobber=True) 1ab
46 self.resolved = (self.insertion.get('json', {'temp': 0}).get('extended_qc'). 1ab
47 get('alignment_resolved', False))
49 self.probe_collection = collection 1ab
51 def load_data(self, prev_alignments=None, xyz_picks=None, depths=None, cluster_chns=None,
52 chn_coords=None):
53 """"
54 Load data required to assess alignment qc and compute similarity matrix. If no arguments
55 are given load_data will fetch all the relevant data required
56 """
57 if not np.any(prev_alignments): 1ab
58 aligned_traj = self.one.alyx.get(f'/trajectories?&probe_insertion={self.eid}' 1a
59 '&provenance=Ephys aligned histology track',
60 clobber=True)
61 if len(aligned_traj) > 0: 1a
62 self.alignments = aligned_traj[0].get('json', {}) 1a
63 else:
64 self.alignments = {} 1a
65 return 1a
66 else:
67 self.alignments = prev_alignments 1ab
69 align_keys = [*self.alignments.keys()] 1ab
70 self.align_keys_sorted = sorted(align_keys, reverse=True) 1ab
72 if not np.any(xyz_picks): 1ab
73 self.xyz_picks = np.array(self.insertion['json']['xyz_picks']) / 1e6 1a
74 else:
75 self.xyz_picks = xyz_picks 1ab
77 if len(self.alignments) < 2: 1ab
78 return 1a
80 if not self.probe_collection: 1ab
81 all_collections = self.one.list_collections(self.insertion['session']) 1ab
82 if f'alf/{self.insertion["name"]}/pykilosort' in all_collections: 1ab
83 self.probe_collection = f'alf/{self.insertion["name"]}/pykilosort'
84 else:
85 self.probe_collection = f'alf/{self.insertion["name"]}' 1ab
87 if not np.any(chn_coords): 1ab
88 self.chn_coords = self.one.load_dataset(self.insertion['session'],
89 'channels.localCoordinates.npy',
90 collection=self.probe_collection)
91 else:
92 self.chn_coords = chn_coords 1ab
94 if not np.any(depths): 1ab
95 self.depths = self.chn_coords[:, 1]
96 else:
97 self.depths = depths 1ab
99 if not np.any(cluster_chns): 1ab
100 self.cluster_chns = self.one.load_dataset(self.insertion['session'],
101 'clusters.channels.npy',
102 collection=self.probe_collection)
103 else:
104 self.cluster_chns = cluster_chns 1ab
106 def compute(self):
107 """
108 Computes the similarity matrix if > 2 alignments. If no data loaded, wraps around load_data
109 to get all relevant data needed
110 """
112 if self.alignments is None: 1ab
113 self.load_data() 1a
115 if len(self.alignments) < 2: 1ab
116 self.log.info(f"Insertion {self.eid}: One or less alignment found...") 1a
117 self.sim_matrix = np.array([len(self.alignments)]) 1a
118 else:
119 self.log.info(f"Insertion {self.eid}: Running QC on alignment data...") 1ab
120 self.sim_matrix = self.compute_similarity_matrix() 1ab
122 return self.sim_matrix 1ab
124 def run(self, update=True, upload_alyx=True, upload_flatiron=True):
125 """
126 Compute alignment_qc for a specified probe insertion and updates extended qc field in alyx.
127 If alignment is resolved and upload flags set to True channels from resolved
128 alignment will be updated to alyx and datasets sent to ibl-ftp-patcher to be uploaded to
129 flatiron
130 """
131 if self.sim_matrix is None: 1ab
132 self.compute() 1ab
134 # Case where the alignment has already been resolved
135 if self.resolved: 1ab
136 self.log.info(f"Alignment for insertion {self.eid} already resolved, channels won't be"
137 f" updated. To force update of channels use "
138 f"resolve_manual method with force=True")
139 results = {'alignment_count': len(self.alignments)}
140 if update:
141 self.update_extended_qc(results)
142 results.update({'alignment_resolved': True})
144 # Case where no alignments have been made
145 elif np.all(self.sim_matrix == 0) and self.sim_matrix.shape[0] == 1: 1ab
146 # We don't update database
147 results = {'alignment_resolved': False} 1a
149 # Case where only one alignment
150 elif np.all(self.sim_matrix == 1) and self.sim_matrix.shape[0] == 1: 1ab
151 results = {'alignment_count': len(self.alignments), 1a
152 'alignment_stored': self.align_keys_sorted[0],
153 'alignment_resolved': False}
154 if update: 1a
155 self.update_extended_qc(results) 1a
157 # Case where 2 or more alignments and alignments haven't been resolved
158 else:
159 results = self.compute_alignment_status() 1ab
161 if update: 1ab
162 self.update_extended_qc(results) 1ab
164 if results['alignment_resolved'] and np.bitwise_or(upload_alyx, upload_flatiron): 1ab
165 self.upload_channels(results['alignment_stored'], upload_alyx, upload_flatiron) 1a
167 return results 1ab
169 def resolve_manual(self, align_key, update=True, upload_alyx=True, upload_flatiron=True,
170 force=False):
171 """
172 Method to manually resolve the alignment of a probe insertion with a given alignment
173 regardless of the number of alignments or the alignment qc value. Channels from specified
174 alignment will be uploaded to alyx and datasets sent to ibl-ftp-patcher to be uploaded to
175 flatiron. If alignment already resolved will only upload if force flag set to True
176 """
178 if self.sim_matrix is None: 1b
179 self.compute() 1b
180 assert align_key in self.align_keys_sorted, 'align key not recognised' 1b
182 if self.resolved == 1 and not force: 1b
183 self.log.info(f"Alignment for insertion {self.eid} already resolved, channels won't be"
184 f"updated. To overwrite stored channels with alignment {align_key} "
185 f"set 'force=True'")
186 file_paths = []
187 else:
188 if self.sim_matrix.shape[0] > 1: 1b
189 results = self.compute_alignment_status() 1b
190 else:
191 results = {'alignment_count': self.sim_matrix.shape[0]}
193 results['alignment_resolved'] = True 1b
194 results['alignment_stored'] = align_key 1b
195 results['alignment_resolved_by'] = 'experimenter' 1b
197 if update: 1b
198 self.update_extended_qc(results) 1b
199 file_paths = [] 1b
201 if upload_alyx or upload_flatiron: 1b
202 file_paths = self.upload_channels(align_key, upload_alyx, upload_flatiron) 1b
204 return file_paths 1b
206 def compute_similarity_matrix(self):
207 """
208 Computes the similarity matrix between each alignment stored in the ephys aligned
209 trajectory. Similarity matrix based on number of clusters that share brain region and
210 parent brain region
211 """
213 r = self.brain_atlas.regions 1ab
215 clusters = dict() 1ab
216 for iK, key in enumerate(self.align_keys_sorted): 1ab
217 # Location of reference lines used for alignment
218 feature = np.array(self.alignments[key][0]) 1ab
219 track = np.array(self.alignments[key][1]) 1ab
221 # Instantiate EphysAlignment object
222 ephysalign = EphysAlignment(self.xyz_picks, self.depths, track_prev=track, 1ab
223 feature_prev=feature,
224 brain_atlas=self.brain_atlas)
226 # Find xyz location of all channels
227 xyz_channels = ephysalign.get_channel_locations(feature, track) 1ab
228 brain_regions = ephysalign.get_brain_locations(xyz_channels) 1ab
230 # Find the location of clusters along the alignment
231 cluster_info = dict() 1ab
232 cluster_info['brain_id'] = brain_regions['id'][self.cluster_chns] 1ab
233 cluster_info['parent_id'] = r.get(ids=cluster_info['brain_id']).parent.astype(int) 1ab
234 clusters.update({key: cluster_info}) 1ab
236 sim_matrix = np.zeros((len(self.align_keys_sorted), len(self.align_keys_sorted))) 1ab
238 for ik, key in enumerate(self.align_keys_sorted): 1ab
239 for ikk, key2 in enumerate(self.align_keys_sorted): 1ab
240 same_id = np.where(clusters[key]['brain_id'] == clusters[key2]['brain_id'])[0] 1ab
241 not_same_id = \ 1ab
242 np.where(clusters[key]['brain_id'] != clusters[key2]['brain_id'])[0]
243 same_parent = np.where(clusters[key]['parent_id'][not_same_id] == 1ab
244 clusters[key2]['parent_id'][not_same_id])[0]
245 sim_matrix[ik, ikk] = len(same_id) + (len(same_parent) * 0.5) 1ab
246 # Normalise
247 sim_matrix_norm = sim_matrix / np.max(sim_matrix) 1ab
249 return sim_matrix_norm 1ab
251 def compute_alignment_status(self):
252 """
253 Determine whether alignments agree based on value in similarity matrix. If any alignments
254 have similarity of 0.8 set the alignment to be resolved
255 """
256 # Set diagonals to zero so we don't use those to find max
257 np.fill_diagonal(self.sim_matrix, 0) 1ab
259 max_sim = np.max(self.sim_matrix) 1ab
261 results = {'alignment_qc': max_sim, 1ab
262 'alignment_count': self.sim_matrix.shape[0]}
264 if max_sim > CRITERIA['PASS']: 1ab
265 location = np.where(self.sim_matrix == max_sim) 1a
266 results.update({'alignment_stored': self.align_keys_sorted[np.min(location)]}) 1a
267 results.update({'alignment_resolved': True}) 1a
268 results.update({'alignment_resolved_by': 'qc'}) 1a
270 else:
271 results.update({'alignment_stored': self.align_keys_sorted[0]}) 1ab
272 results.update({'alignment_resolved': False}) 1ab
274 return results 1ab
276 def upload_channels(self, alignment_key, upload_alyx, upload_flatiron):
277 """
278 Upload channels to alyx and flatiron based on the alignment specified by the alignment key
279 """
281 feature = np.array(self.alignments[alignment_key][0]) 1ab
282 track = np.array(self.alignments[alignment_key][1]) 1ab
284 try: 1ab
285 meta_dset = self.one.list_datasets(self.insertion['session'], '*ap.meta', 1ab
286 collection=f'raw_ephys_data/{self.insertion["name"]}')
288 meta_file = self.one.load_dataset(self.insertion['session'], meta_dset[0].split('/')[-1], 1ab
289 collection=f'raw_ephys_data/{self.insertion["name"]}',
290 download_only=True)
291 geometry = spikeglx.read_geometry(meta_file)
292 chns = np.c_[geometry['x'], geometry['y']]
293 except Exception as err: 1ab
294 self.log.warning(f"Could not compute channel locations from meta file, errored with message: {err}. " 1ab
295 f"Will use default Neuropixel 1 channels")
296 geometry = trace_header(version=1) 1ab
297 chns = np.c_[geometry['x'], geometry['y']] 1ab
299 ephysalign = EphysAlignment(self.xyz_picks, chns[:, 1], 1ab
300 track_prev=track,
301 feature_prev=feature,
302 brain_atlas=self.brain_atlas)
303 channels_mlapdv = np.int32(ephysalign.get_channel_locations(feature, track) * 1e6) 1ab
304 channels_atlas_id = ephysalign.get_brain_locations(channels_mlapdv / 1e6)['id'] 1ab
306 # Need to change channels stored on alyx as well as the stored key is not the same as the latest key
307 if upload_alyx: 1ab
308 if alignment_key != self.align_keys_sorted[0]: 1ab
309 histology.register_aligned_track(self.eid, channels_mlapdv / 1e6, 1ab
310 chn_coords=chns, one=self.one,
311 overwrite=True, channels=self.channels_flag,
312 brain_atlas=self.brain_atlas)
314 ephys_traj = self.one.alyx.get(f'/trajectories?&probe_insertion={self.eid}' 1ab
315 '&provenance=Ephys aligned histology track',
316 clobber=True)
317 patch_dict = {'json': self.alignments} 1ab
318 self.one.alyx.rest('trajectories', 'partial_update', id=ephys_traj[0]['id'], 1ab
319 data=patch_dict)
321 files_to_register = [] 1ab
322 if upload_flatiron: 1ab
323 ftp_patcher = FTPPatcher(one=self.one)
325 alf_path = self.one.eid2path(self.insertion['session']).joinpath('alf', self.insertion["name"])
326 alf_path.mkdir(exist_ok=True, parents=True)
328 f_name = alf_path.joinpath('electrodeSites.mlapdv.npy')
329 np.save(f_name, channels_mlapdv)
330 files_to_register.append(f_name)
332 f_name = alf_path.joinpath('electrodeSites.brainLocationIds_ccf_2017.npy')
333 np.save(f_name, channels_atlas_id)
334 files_to_register.append(f_name)
336 f_name = alf_path.joinpath('electrodeSites.localCoordinates.npy')
337 np.save(f_name, chns)
338 files_to_register.append(f_name)
340 probe_collections = self.one.list_collections(self.insertion['session'], filename='channels*',
341 collection=f'alf/{self.insertion["name"]}*')
343 for collection in probe_collections:
344 chns = self.one.load_dataset(self.insertion['session'], 'channels.localCoordinates', collection=collection)
345 ephysalign = EphysAlignment(self.xyz_picks, chns[:, 1],
346 track_prev=track,
347 feature_prev=feature,
348 brain_atlas=self.brain_atlas)
349 channels_mlapdv = np.int32(ephysalign.get_channel_locations(feature, track) * 1e6)
350 channels_atlas_id = ephysalign.get_brain_locations(channels_mlapdv / 1e6)['id']
352 alf_path = self.one.eid2path(self.insertion['session']).joinpath(collection)
353 alf_path.mkdir(exist_ok=True, parents=True)
355 f_name = alf_path.joinpath('channels.mlapdv.npy')
356 np.save(f_name, channels_mlapdv)
357 files_to_register.append(f_name)
359 f_name = alf_path.joinpath('channels.brainLocationIds_ccf_2017.npy')
360 np.save(f_name, channels_atlas_id)
361 files_to_register.append(f_name)
363 self.log.info("Writing datasets to FlatIron")
364 ftp_patcher.create_dataset(path=files_to_register,
365 created_by=self.one.alyx.user)
367 return files_to_register 1ab
369 def update_experimenter_evaluation(self, prev_alignments=None, override=False):
371 if not np.any(prev_alignments) and not np.any(self.alignments):
372 aligned_traj = self.one.alyx.get(f'/trajectories?&probe_insertion={self.eid}'
373 '&provenance=Ephys aligned histology track',
374 clobber=True)
375 if len(aligned_traj) > 0:
376 self.alignments = aligned_traj[0].get('json', {})
377 else:
378 self.alignments = {}
379 return
380 else:
381 self.alignments = prev_alignments
383 outcomes = [align[2].split(':')[0] for key, align in self.alignments.items()
384 if len(align) == 3]
385 if len(outcomes) > 0:
386 vals = [CRITERIA_BASE[out] for out in outcomes]
387 max_qc = np.argmax(vals)
388 outcome = outcomes[max_qc]
389 self.update(outcome, namespace='experimenter', override=override)
390 else:
391 self.log.warning(f'No experimenter qc found, qc field of probe insertion {self.eid} '
392 f'will not be updated')
395def get_aligned_channels(ins, chn_coords, one, ba=None, save_dir=None):
397 ba = ba or AllenAtlas(25)
398 depths = chn_coords[:, 1]
399 xyz = np.array(ins['json']['xyz_picks']) / 1e6
400 traj = one.alyx.rest('trajectories', 'list', probe_insertion=ins['id'],
401 provenance='Ephys aligned histology track')[0]
402 align_key = ins['json']['extended_qc']['alignment_stored']
403 feature = traj['json'][align_key][0]
404 track = traj['json'][align_key][1]
405 ephysalign = EphysAlignment(xyz, depths, track_prev=track,
406 feature_prev=feature,
407 brain_atlas=ba, speedy=True)
408 channels_mlapdv = np.int32(ephysalign.get_channel_locations(feature, track) * 1e6)
409 channels_atlas_ids = ephysalign.get_brain_locations(channels_mlapdv / 1e6)['id']
411 out_files = []
412 if save_dir is not None:
413 f_name = Path(save_dir).joinpath('channels.mlapdv.npy')
414 np.save(f_name, channels_mlapdv)
415 out_files.append(f_name)
417 f_name = Path(save_dir).joinpath('channels.brainLocationIds_ccf_2017.npy')
418 np.save(f_name, channels_atlas_ids)
419 out_files.append(f_name)
421 return out_files