diff --git a/.github/workflows/ibllib_ci.yml b/.github/workflows/ibllib_ci.yml index 20564820d..30e71a628 100644 --- a/.github/workflows/ibllib_ci.yml +++ b/.github/workflows/ibllib_ci.yml @@ -33,12 +33,13 @@ jobs: - name: Install deps run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest + python -m pip install flake8 pytest flake8-docstrings pip install -r requirements.txt pip install -e . - name: Flake8 run: | python -m flake8 + python -m flake8 --select D --ignore E ibllib/qc/camera.py - name: Brainbox tests run: | cd brainbox diff --git a/brainbox/examples/docs_wheel_screen_stimulus.ipynb b/brainbox/examples/docs_wheel_screen_stimulus.ipynb new file mode 100644 index 000000000..018e3c012 --- /dev/null +++ b/brainbox/examples/docs_wheel_screen_stimulus.ipynb @@ -0,0 +1,424 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63c72402", + "metadata": {}, + "source": [ + "# Computing the stimulus position using the wheel" + ] + }, + { + "cell_type": "markdown", + "id": "cd4144b5", + "metadata": {}, + "source": [ + "In the IBL task a visual stimulus (Gabor patch of size 7°2) appears on the left (-35°) or right (+35°) of a screen and the mouse must use a wheel to bring the stimulus to the centre of the screen (0°). If the mouse moves the wheel in the correct direction, the trial is deemed correct and the mouse receives a reward. If however, the mouse moves the wheel 35° in the wrong direction and the stimulus goes off the screen, this is an error trial and the mouse receives a white noise error tone. The screen was positioned 8 cm in front of the animal and centralized relative to the position of eyes to cover ~102 visual degree azimuth. In the case that the mouse moves the stimulus 35° in the wrong direction, the stimulus, therefore is visible for 20° and the rest is off the screen.\n", + "\n", + "For some analyses it may be useful to know the position of the visual stimulus on the screen during a trial. While there is no direct read out of the location of the stimulus on the screen, as the stimulus is coupled to the wheel, we can infer the position using the wheel position. \n", + "\n", + "Below we walk you through an example of how to compute the continuous stimulus position on the screen for a given trial.\n", + "\n", + "For this anaylsis we need access to information about the wheel radius (3.1 cm) and the wheel gain (visual degrees moved on screen per mm of wheel movement). The wheel gain changes throughout the training period (see our [behavior paper](https://doi.org/10.7554/eLife.63711\n", + ") for more information) but for the majority of sessions is set at 4°/mm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ae5f990", + "metadata": { + "nbsphinx": "hidden" + }, + "outputs": [], + "source": [ + "# Turn off logging and disable tqdm this is a hidden cell on docs page\n", + "import logging\n", + "import os\n", + "\n", + "logger = logging.getLogger('ibllib')\n", + "logger.setLevel(logging.CRITICAL)\n", + "\n", + "os.environ[\"TQDM_DISABLE\"] = \"1\"" + ] + }, + { + "cell_type": "markdown", + "id": "4014587e", + "metadata": {}, + "source": [ + "## Step 1: Load data" + ] + }, + { + "cell_type": "markdown", + "id": "402f50ce", + "metadata": {}, + "source": [ + "For this analysis we will need to load in the trials and wheel data for a chosen session" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b92f69b", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.018526Z", + "start_time": "2024-04-24T08:31:07.846690Z" + } + }, + "outputs": [], + "source": [ + "from one.api import ONE\n", + "one = ONE(base_url='https://openalyx.internationalbrainlab.org')\n", + "\n", + "eid = 'f88d4dd4-ccd7-400e-9035-fa00be3bcfa8'\n", + "trials = one.load_object(eid, 'trials')\n", + "wheel = one.load_object(eid, 'wheel')" + ] + }, + { + "cell_type": "markdown", + "id": "2b7aa84b", + "metadata": {}, + "source": [ + "## Step 2: Compute evenly sampled wheel data" + ] + }, + { + "cell_type": "markdown", + "id": "bfecd27e", + "metadata": {}, + "source": [ + "The wheel data returned is not evenly sampled, we can sample the data at 1000 Hz using the following function" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2c7b03d", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.079343Z", + "start_time": "2024-04-24T08:31:09.022391Z" + } + }, + "outputs": [], + "source": [ + "import brainbox.behavior.wheel as wh\n", + "wheel_pos, wheel_times = wh.interpolate_position(wheel.timestamps, wheel.position, freq=1000)" + ] + }, + { + "cell_type": "markdown", + "id": "c054fc52", + "metadata": {}, + "source": [ + "## Step 3: Extract wheel data for a given trial" + ] + }, + { + "cell_type": "markdown", + "id": "e4c4b1fd", + "metadata": {}, + "source": [ + "We now want to find the wheel data in the interval for a given trial" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "600a7b6c", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.085925Z", + "start_time": "2024-04-24T08:31:09.084116Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "# Choose trial no. 110 (right contrast = 1) ; or 150 (left)\n", + "tr_idx = 110\n", + "# Get interval of trial, gives two values, start of trial and end of trial\n", + "interval = trials['intervals'][tr_idx]\n", + "# Find the index of the wheel timestamps that contain this interval\n", + "wheel_idx = np.searchsorted(wheel_times, interval)\n", + "# Limit our wheel data to these indexes\n", + "wh_pos = wheel_pos[wheel_idx[0]:wheel_idx[1]]\n", + "wh_times = wheel_times[wheel_idx[0]:wheel_idx[1]]" + ] + }, + { + "cell_type": "markdown", + "id": "56a8f59c", + "metadata": {}, + "source": [ + "## Step 4: Compute the position in mm" + ] + }, + { + "cell_type": "markdown", + "id": "57b5b487", + "metadata": {}, + "source": [ + "The values for the wheel position are given in radians. Since the wheel gain is defined in visual degrees per mm we need to convert the wheel position to mm. We can use the radius of the wheel for this." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "785cb8ba", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.092109Z", + "start_time": "2024-04-24T08:31:09.090155Z" + } + }, + "outputs": [], + "source": [ + "# radius of wheel in mm\n", + "WHEEL_RADIUS = 3.1 * 10 \n", + "# compute circumference of wheel\n", + "wh_circ = 2 * np.pi * WHEEL_RADIUS\n", + "# compute the mm turned be wheel degree\n", + "mm_per_wh_deg = wh_circ / 360\n", + "# convert wh_pos from radians to degrees\n", + "wh_pos = wh_pos * 180 / np.pi\n", + "# convert wh_pos from degrees to mm\n", + "wh_pos = wh_pos * mm_per_wh_deg" + ] + }, + { + "cell_type": "markdown", + "id": "d1623b1d", + "metadata": {}, + "source": [ + "## Step 5: Compute the wheel displacement from stimOn" + ] + }, + { + "cell_type": "markdown", + "id": "493661dc", + "metadata": {}, + "source": [ + "To link the visual stimulus movement to the wheel position we need to compute the displacement of the wheel position relative to the time at which the stimulus first appears on the screen." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc95dd15", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.096554Z", + "start_time": "2024-04-24T08:31:09.094108Z" + } + }, + "outputs": [], + "source": [ + "# Find the index of the wheel timestamps when the stimulus was presented (stimOn_times)\n", + "idx_stim = np.searchsorted(wh_times, trials['stimOn_times'][tr_idx])\n", + "# Zero the wh_pos to the position at stimOn\n", + "wh_pos = wh_pos - wh_pos[idx_stim]" + ] + }, + { + "cell_type": "markdown", + "id": "3f3c1843", + "metadata": {}, + "source": [ + "## Step 6: Convert wheel displacement to screen position" + ] + }, + { + "cell_type": "markdown", + "id": "93ebf279", + "metadata": {}, + "source": [ + "Now that we have computed the displacement of the wheel relative to when the stimulus was presented we can use the wheel gain to convert this into degrees of the visual stimlus on the screen." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27b9e495", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.118395Z", + "start_time": "2024-04-24T08:31:09.098359Z" + } + }, + "outputs": [], + "source": [ + "GAIN_MM_TO_SC_DEG = 4\n", + "stim_pos = wh_pos * GAIN_MM_TO_SC_DEG" + ] + }, + { + "cell_type": "markdown", + "id": "fea32dca", + "metadata": {}, + "source": [ + "## Step 7: Fixing screen position linked to events" + ] + }, + { + "cell_type": "markdown", + "id": "e0189229", + "metadata": {}, + "source": [ + "The stim_pos values that we have above have been computed over the whole trial interval, from trial start to trial end. The stimlus on the screen however is can only move with the wheel between the time at which the stimlus is presented (stimOn_times) and the time at which a choice is made (response_times). After a response is made the visual stimulus then remains in a fixed position until the it disappears from the screen (stimOff_times)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11d86179", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.229711Z", + "start_time": "2024-04-24T08:31:09.227047Z" + } + }, + "outputs": [], + "source": [ + "# Find the index of the wheel timestamps when the stimulus was presented (stimOn_times)\n", + "idx_stim = np.searchsorted(wh_times, trials['stimOn_times'][tr_idx])\n", + "# Find the index of the wheel timestamps when the choice was made (response_times)\n", + "idx_res = np.searchsorted(wh_times, trials['response_times'][tr_idx])\n", + "# Find the index of the wheel timestamps when the stimulus disappears (stimOff_times)\n", + "idx_off = np.searchsorted(wh_times, trials['response_times'][tr_idx])\n", + "\n", + "# Before stimOn no stimulus on screen, so set to nan\n", + "stim_pos[0:idx_stim - 1] = np.nan\n", + "# Stimulus is in a fixed position between response time and stimOff time\n", + "stim_pos[idx_res:idx_off - 1] = stim_pos[idx_res]\n", + "# After stimOff no stimulus on screen, so set to nan\n", + "stim_pos[idx_off:] = np.nan" + ] + }, + { + "cell_type": "markdown", + "id": "781fe47f", + "metadata": {}, + "source": [ + "The stim_pos values are given relative to stimOn times but the stimulus appears at either -35° or 35° depending on the stimlus side. We therefore need to apply this offset to our stimulus position. We also need to account for the convention that increasing wheel position indicates a counter-clockwise movement and therefore a left-ward (-ve) movement of the stimulus in visual azimuth." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b89e9e87", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.412405Z", + "start_time": "2024-04-24T08:31:09.410332Z" + } + }, + "outputs": [], + "source": [ + "# offset depends on whether stimulus was shown on left or right of screen\n", + "\n", + "ONSET_OFFSET = 35\n", + "\n", + "if np.isnan(trials['contrastLeft'][tr_idx]):\n", + " # The stimulus appeared on the right\n", + " # Values for the screen position will be >0\n", + " offset = ONSET_OFFSET # The stimulus starts at +35 and goes to --> 0\n", + " stim_pos = -1 * stim_pos + offset\n", + "else:\n", + " # The stimulus appeared on the left\n", + " # Values for the screen position will be <0\n", + " offset = -1 * ONSET_OFFSET # The stimulus starts at -35 and goes to --> 0\n", + " stim_pos = -1 * stim_pos + offset" + ] + }, + { + "cell_type": "markdown", + "id": "7fc5d580", + "metadata": {}, + "source": [ + "## Step 8: Plotting our results" + ] + }, + { + "cell_type": "markdown", + "id": "ee7874ec", + "metadata": {}, + "source": [ + "Finally we can plot our results to see if they make sense" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e3fb652", + "metadata": { + "ExecuteTime": { + "end_time": "2024-04-24T08:31:09.772469Z", + "start_time": "2024-04-24T08:31:09.418855Z" + } + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "fig, axs = plt.subplots(2, 1, sharex=True, height_ratios=[1, 3])\n", + "\n", + "# On top axis plot the wheel displacement\n", + "axs[0].plot(wh_times, wh_pos, 'k')\n", + "axs[0].vlines([trials['stimOn_times'][tr_idx], trials['response_times'][tr_idx]],\n", + " 0, 1, transform=axs[0].get_xaxis_transform(), colors='k', linestyles='dashed')\n", + "axs[0].text(trials['stimOn_times'][tr_idx], 1.01, 'stimOn', c='k', rotation=20,\n", + " rotation_mode='anchor', ha='left', transform=axs[0].get_xaxis_transform())\n", + "axs[0].text(trials['response_times'][tr_idx], 1.01, 'response', c='k', rotation=20,\n", + " rotation_mode='anchor', ha='left', transform=axs[0].get_xaxis_transform())\n", + "axs[0].set_ylabel('Wheel displacement (mm)')\n", + "\n", + "\n", + "# On bottom axis plot the stimulus position\n", + "axs[1].plot(wh_times, stim_pos, 'k')\n", + "axs[1].vlines([trials['stimOn_times'][tr_idx], trials['response_times'][tr_idx]],\n", + " 0, 1, transform=axs[1].get_xaxis_transform(), colors='k', linestyles='dashed')\n", + "axs[1].set_xlim(trials['intervals'][tr_idx])\n", + "# black dotted lines indicate starting stimulus position\n", + "axs[1].hlines([-35, 35], *axs[1].get_xlim(), colors='k', linestyles='dotted')\n", + "# green line indicates threshold for good trial\n", + "axs[1].hlines([0], *axs[1].get_xlim(), colors='g', linestyles='solid')\n", + "# red lines indicate threshold for incorrect trial\n", + "axs[1].hlines([-70, 70], *axs[1].get_xlim(), colors='r', linestyles='solid')\n", + "\n", + "axs[1].set_ylim([-90, 90])\n", + "axs[1].set_xlim(trials['stimOn_times'][tr_idx] - 0.1, trials['response_times'][tr_idx] + 0.1)\n", + "axs[1].set_ylabel('Visual azimuth angle (°)')\n", + "axs[1].set_xlabel('Time in session (s)')\n", + "fig.suptitle(f\"ContrastLeft: {trials['contrastLeft'][tr_idx]}, ContrastRight: {trials['contrastRight'][tr_idx]},\"\n", + " f\"FeedbackType {trials['feedbackType'][tr_idx]}\")\n", + "\n" + ] + } + ], + "metadata": { + "celltoolbar": "Edit Metadata", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/brainbox/io/one.py b/brainbox/io/one.py index 4c09579f3..dd2ac4bba 100644 --- a/brainbox/io/one.py +++ b/brainbox/io/one.py @@ -12,8 +12,8 @@ import matplotlib.pyplot as plt from one.api import ONE, One -from one.alf.files import get_alf_path -from one.alf.exceptions import ALFObjectNotFound +from one.alf.files import get_alf_path, full_path_parts +from one.alf.exceptions import ALFObjectNotFound, ALFMultipleCollectionsFound from one.alf import cache import one.alf.io as alfio from neuropixel import TIP_SIZE_UM, trace_header @@ -1324,18 +1324,48 @@ def load_session_data(self, trials=True, wheel=True, pose=True, motion_energy=Tr _logger.warning(f"Could not load {row['name']} data.") _logger.debug(e) - def load_trials(self): + def _find_behaviour_collection(self, obj): + """ + Function to find the trial or wheel collection + + Parameters + ---------- + obj: str + Alf object to load, either 'trials' or 'wheel' + """ + dataset = '_ibl_trials.table.pqt' if obj == 'trials' else '_ibl_wheel.position.npy' + dsets = self.one.list_datasets(self.eid, dataset) + if len(dsets) == 0: + return 'alf' + else: + collections = [full_path_parts(self.session_path.joinpath(d), as_dict=True)['collection'] for d in dsets] + if len(set(collections)) == 1: + return collections[0] + else: + _logger.error(f'Multiple collections found {collections}. Specify collection when loading, ' + f'e.g sl.load_{obj}(collection="{collections[0]}")') + raise ALFMultipleCollectionsFound + + def load_trials(self, collection=None): """ Function to load trials data into SessionLoader.trials + + Parameters + ---------- + collection: str + Alf collection of trials data """ + + if not collection: + collection = self._find_behaviour_collection('trials') # itiDuration frequently has a mismatched dimension, and we don't need it, exclude using regex self.one.wildcards = False self.trials = self.one.load_object( - self.eid, 'trials', collection='alf', attribute=r'(?!itiDuration).*').to_df() + self.eid, 'trials', collection=collection, attribute=r'(?!itiDuration).*').to_df() self.one.wildcards = True self.data_info.loc[self.data_info['name'] == 'trials', 'is_loaded'] = True - def load_wheel(self, fs=1000, corner_frequency=20, order=8): + def load_wheel(self, fs=1000, corner_frequency=20, order=8, collection=None): """ Function to load wheel data (position, velocity, acceleration) into SessionLoader.wheel. The wheel position is first interpolated to a uniform sampling rate. Then velocity and acceleration are computed, during which @@ -1349,8 +1379,12 @@ def load_wheel(self, fs=1000, corner_frequency=20, order=8): Corner frequency of Butterworth low-pass filter, default is 20 order: int, float Order of Butterworth low_pass filter, default is 8 + collection: str + Alf collection of wheel data """ - wheel_raw = self.one.load_object(self.eid, 'wheel') + if not collection: + collection = self._find_behaviour_collection('wheel') + wheel_raw = self.one.load_object(self.eid, 'wheel', collection=collection) if wheel_raw['position'].shape[0] != wheel_raw['timestamps'].shape[0]: raise ValueError("Length mismatch between 'wheel.position' and 'wheel.timestamps") # resample the wheel position and compute velocity, acceleration diff --git a/examples/exploring_data/data_download.ipynb b/examples/exploring_data/data_download.ipynb index 3e4b8961a..bfaca800f 100644 --- a/examples/exploring_data/data_download.ipynb +++ b/examples/exploring_data/data_download.ipynb @@ -3,7 +3,9 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "nbsphinx": "hidden" + }, "outputs": [], "source": [ "# Turn off logging and disable tqdm this is a hidden cell on docs page\n", @@ -19,7 +21,6 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false, "nbsphinx": "hidden" }, "source": [ @@ -32,9 +33,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "## Installation\n", "### Environment\n", @@ -74,9 +73,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "## Explore and download data using the ONE-api\n", "\n", @@ -94,9 +91,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### Launch the ONE-api\n", "Prior to do any searching / downloading, you need to instantiate ONE :" @@ -114,9 +109,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### List all sessions available\n", "Once ONE is instantiated, you can use the REST ONE-api to list all sessions publicly available:" @@ -133,9 +126,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "Each session is given a unique identifier (eID); this eID is what you will use to download data for a given session:" ] @@ -152,9 +143,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### Find a session that has a dataset of interest\n", "Not all sessions will have all the datasets available. As such, it may be important for you to filter and search for only sessions with particular datasets of interest.\n", @@ -170,14 +159,12 @@ "outputs": [], "source": [ "# Find sessions that have spikes.times datasets\n", - "sessions_with_spikes = one.search(project='brainwide', data='spikes.times')" + "sessions_with_spikes = one.search(project='brainwide', dataset='spikes.times')" ] }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "[Click here](https://int-brain-lab.github.io/ONE/notebooks/one_search/one_search.html) for a complete guide to searching using ONE.\n", "\n", @@ -200,9 +187,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "You can use the tag to restrict your searches to a specific data release and as a filter when browsing the public database:" ] @@ -213,6 +198,7 @@ "metadata": {}, "outputs": [], "source": [ + "%%capture\n", "# Note that tags are associated with datasets originally\n", "# You can load a local index of sessions and datasets associated with a specific data release\n", "one.load_cache(tag='2022_Q2_IBL_et_al_RepeatedSite')\n", @@ -230,9 +216,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### Downloading data using the ONE-api\n", "Once sessions of interest are identified with the unique identifier (eID), all files ready for analysis are found in the **alf** collection:" @@ -245,7 +229,7 @@ "outputs": [], "source": [ "# Find an example session with data\n", - "eid, *_ = one.search(project='brainwide', data='alf/')\n", + "eid, *_ = one.search(project='brainwide', dataset='alf/')\n", "# List datasets associated with a session, in the alf collection\n", "datasets = one.list_datasets(eid, collection='alf*')\n", "\n", @@ -258,9 +242,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "To download the spike sorting data we need to find out which probe label (`probeXX`) was used for this session. This can be done by finding the probe insertion associated with this session." ] @@ -273,7 +255,7 @@ "source": [ "# Find an example session with spike data\n", "# Note: Restricting by task and project makes searching for data much quicker\n", - "eid, *_ = one.search(project='brainwide', data='spikes', task='ephys')\n", + "eid, *_ = one.search(project='brainwide', dataset='spikes', task='ephys')\n", "\n", "# Data for each probe insertion are stored in the alf/probeXX folder.\n", "datasets = one.list_datasets(eid, collection='alf/probe*')\n", @@ -291,9 +273,7 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### Loading different objects\n", "\n", @@ -319,18 +299,14 @@ }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "Examples for loading different objects can be found in the following tutorials [here](https://int-brain-lab.github.io/iblenv/loading_examples.html)." ] }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "### Advanced examples\n", "#### Example 1: Searching for sessions from a specific lab\n", @@ -344,15 +320,14 @@ "metadata": {}, "outputs": [], "source": [ + "%%capture\n", "one.load_cache(tag='2022_Q2_IBL_et_al_RepeatedSite')\n", "sessions_lab = one.search(lab='mrsicflogellab')" ] }, { "cell_type": "markdown", - "metadata": { - "collapsed": false - }, + "metadata": {}, "source": [ "However, if you wanted to query only the data for a given lab, it might be most judicious to first\n", "know the list of all labs available, select an arbitrary lab name from it, and query the specific sessions from it." @@ -376,13 +351,12 @@ "lab_name = list(labs)[0]\n", "\n", "# Searching for RS sessions with specific lab name\n", - "sessions_lab = one.search(data='spikes', lab=lab_name)" + "sessions_lab = one.search(dataset='spikes', lab=lab_name)" ] }, { "cell_type": "markdown", "metadata": { - "collapsed": false, "pycharm": { "name": "#%% md\n" } @@ -412,8 +386,9 @@ } ], "metadata": { + "celltoolbar": "Edit Metadata", "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -427,9 +402,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.6" + "version": "3.9.16" } }, "nbformat": 4, - "nbformat_minor": 0 + "nbformat_minor": 1 } diff --git a/examples/loading_data/loading_passive_data.ipynb b/examples/loading_data/loading_passive_data.ipynb index 5d3e03114..68852fee0 100644 --- a/examples/loading_data/loading_passive_data.ipynb +++ b/examples/loading_data/loading_passive_data.ipynb @@ -17,10 +17,14 @@ }, "outputs": [], "source": [ - "# Turn off logging, this is a hidden cell on docs page\n", + "# Turn off logging and disable tqdm this is a hidden cell on docs page\n", "import logging\n", + "import os\n", + "\n", "logger = logging.getLogger('ibllib')\n", - "logger.setLevel(logging.CRITICAL)" + "logger.setLevel(logging.CRITICAL)\n", + "\n", + "os.environ[\"TQDM_DISABLE\"] = \"1\"" ] }, { @@ -67,9 +71,7 @@ "cell_type": "code", "execution_count": null, "id": "2b807296", - "metadata": { - "ibl_execute": false - }, + "metadata": {}, "outputs": [], "source": [ "from one.api import ONE\n", @@ -92,9 +94,7 @@ "cell_type": "code", "execution_count": null, "id": "811e3533", - "metadata": { - "ibl_execute": false - }, + "metadata": {}, "outputs": [], "source": [ "from brainbox.io.one import load_passive_rfmap\n", @@ -114,9 +114,7 @@ "cell_type": "code", "execution_count": null, "id": "c65f1ca8", - "metadata": { - "ibl_execute": false - }, + "metadata": {}, "outputs": [], "source": [ "# Load visual stimulus task replay events\n", @@ -167,9 +165,7 @@ "cell_type": "code", "execution_count": null, "id": "7552f7c5", - "metadata": { - "ibl_execute": false - }, + "metadata": {}, "outputs": [], "source": [ "# Find first probe insertion for session\n", @@ -207,9 +203,7 @@ "cell_type": "code", "execution_count": null, "id": "eebdc9af", - "metadata": { - "ibl_execute": false - }, + "metadata": {}, "outputs": [], "source": [ "# Find out at what times each voxel on the screen was turned 'on' (grey to white) or turned 'off' (grey to black)\n", @@ -236,9 +230,9 @@ "metadata": { "celltoolbar": "Edit Metadata", "kernelspec": { - "display_name": "Python [conda env:iblenv] *", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "conda-env-iblenv-py" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -250,7 +244,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.6" + "version": "3.9.16" } }, "nbformat": 4, diff --git a/examples/loading_data/loading_raw_ephys_data.ipynb b/examples/loading_data/loading_raw_ephys_data.ipynb index 30b2d3b42..458ab82ce 100644 --- a/examples/loading_data/loading_raw_ephys_data.ipynb +++ b/examples/loading_data/loading_raw_ephys_data.ipynb @@ -50,6 +50,7 @@ "metadata": {}, "outputs": [], "source": [ + "%%capture\n", "from one.api import ONE\n", "from brainbox.io.one import SpikeSortingLoader\n", "\n", @@ -70,7 +71,6 @@ "cell_type": "markdown", "id": "541898a2492f2c14", "metadata": { - "collapsed": false, "jupyter": { "outputs_hidden": false } @@ -97,6 +97,7 @@ "metadata": {}, "outputs": [], "source": [ + "%%capture\n", "stimOn_times = one.load_object(ssl.eid, 'trials', collection='alf')['stimOn_times']\n", "event_no = 100\n", "# timepoint in recording to stream, as per the experiment main clock \n", @@ -185,7 +186,6 @@ "cell_type": "markdown", "id": "d7dba84029780138", "metadata": { - "collapsed": false, "jupyter": { "outputs_hidden": false } @@ -359,6 +359,7 @@ "metadata": {}, "outputs": [], "source": [ + "%%capture\n", "from one.api import ONE\n", "from brainbox.io.spikeglx import Streamer\n", "\n", @@ -481,7 +482,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.6" + "version": "3.9.16" } }, "nbformat": 4, diff --git a/examples/loading_data/loading_raw_video_data.ipynb b/examples/loading_data/loading_raw_video_data.ipynb index 8b8c9eb9e..959e85dc7 100644 --- a/examples/loading_data/loading_raw_video_data.ipynb +++ b/examples/loading_data/loading_raw_video_data.ipynb @@ -293,7 +293,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.6" + "version": "3.9.16" } }, "nbformat": 4, diff --git a/ibllib/__init__.py b/ibllib/__init__.py index b8e978b52..4643bfb1a 100644 --- a/ibllib/__init__.py +++ b/ibllib/__init__.py @@ -2,7 +2,7 @@ import logging import warnings -__version__ = '2.34.0' +__version__ = '2.35.0' warnings.filterwarnings('always', category=DeprecationWarning, module='ibllib') # if this becomes a full-blown library we should let the logging configuration to the discretion of the dev diff --git a/ibllib/io/extractors/biased_trials.py b/ibllib/io/extractors/biased_trials.py index 5b82a46c1..3be854179 100644 --- a/ibllib/io/extractors/biased_trials.py +++ b/ibllib/io/extractors/biased_trials.py @@ -45,9 +45,10 @@ def _extract(self, **kwargs): @staticmethod def get_pregenerated_events(bpod_trials, settings): - num = settings.get("PRELOADED_SESSION_NUM", None) - if num is None: - num = settings.get("PREGENERATED_SESSION_NUM", None) + for k in ['PRELOADED_SESSION_NUM', 'PREGENERATED_SESSION_NUM', 'SESSION_TEMPLATE_ID']: + num = settings.get(k, None) + if num is not None: + break if num is None: fn = settings.get('SESSION_LOADED_FILE_PATH', '') fn = PureWindowsPath(fn).name diff --git a/ibllib/oneibl/data_handlers.py b/ibllib/oneibl/data_handlers.py index 9b6a9f14f..11b8ff904 100644 --- a/ibllib/oneibl/data_handlers.py +++ b/ibllib/oneibl/data_handlers.py @@ -14,7 +14,7 @@ from one.api import ONE from one.webclient import AlyxClient -from one.util import filter_datasets +from one.util import filter_datasets, ensure_list from one.alf.files import add_uuid_string, session_path_parts from ibllib.oneibl.registration import register_dataset, get_lab, get_local_data_repository from ibllib.oneibl.patcher import FTPPatcher, SDSCPatcher, SDSC_ROOT_PATH, SDSC_PATCH_PATH @@ -140,8 +140,7 @@ def uploadData(self, outputs, version, clobber=False, **kwargs): versions = super().uploadData(outputs, version) data_repo = get_local_data_repository(self.one.alyx) # If clobber = False, do not re-upload the outputs that have already been processed - if not isinstance(outputs, list): - outputs = [outputs] + outputs = ensure_list(outputs) to_upload = list(filter(None if clobber else lambda x: x not in self.processed, outputs)) records = register_dataset(to_upload, one=self.one, versions=versions, repository=data_repo, **kwargs) or [] if kwargs.get('dry', False): diff --git a/ibllib/pipes/dynamic_pipeline.py b/ibllib/pipes/dynamic_pipeline.py index ccd7128db..7024d4ce3 100644 --- a/ibllib/pipes/dynamic_pipeline.py +++ b/ibllib/pipes/dynamic_pipeline.py @@ -474,6 +474,9 @@ def get_trials_tasks(session_path, one=None): # Check for an experiment.description file; ensure downloaded if possible if one and one.to_eid(session_path): # to_eid returns None if session not registered one.load_datasets(session_path, ['_ibl_experiment.description'], download_only=True, assert_present=False) + # NB: meta files only required to build neuropixel tasks in make_pipeline + if meta_files := one.list_datasets(session_path, '*.ap.meta', collection='raw_ephys_data*'): + one.load_datasets(session_path, meta_files, download_only=True, assert_present=False) experiment_description = sess_params.read_params(session_path) # If experiment description file then use this to make the pipeline diff --git a/ibllib/pipes/histology.py b/ibllib/pipes/histology.py index 7988ae542..6e6ed900b 100644 --- a/ibllib/pipes/histology.py +++ b/ibllib/pipes/histology.py @@ -278,8 +278,10 @@ def register_track(probe_id, picks=None, one=None, overwrite=False, channels=Tru } if endpoint == 'chronic-insertions': tdict['chronic_insertion'] = probe_id + tdict['probe_insertion'] = None else: tdict['probe_insertion'] = probe_id + tdict['chronic_insertion'] = None brain_locations = None # Update the insertion qc to CRITICAL diff --git a/ibllib/pipes/mesoscope_tasks.py b/ibllib/pipes/mesoscope_tasks.py index 683395431..906ab7f27 100644 --- a/ibllib/pipes/mesoscope_tasks.py +++ b/ibllib/pipes/mesoscope_tasks.py @@ -578,7 +578,7 @@ def _run(self, run_suite2p=True, rename_files=True, use_badframes=True, **kwargs """ Bad frames """ qc_paths = (self.session_path.joinpath(f[1], 'exptQC.mat') for f in self.input_files if f[0] == 'exptQC.mat') - qc_paths = map(str, filter(Path.exists, qc_paths)) + qc_paths = sorted(map(str, filter(Path.exists, qc_paths))) exptQC = [loadmat(p, squeeze_me=True, simplify_cells=True) for p in qc_paths] if len(exptQC) > 0: frameQC, frameQC_names, bad_frames = self._consolidate_exptQC(exptQC) diff --git a/ibllib/pipes/video_tasks.py b/ibllib/pipes/video_tasks.py index 86cb49d33..eab657a0a 100644 --- a/ibllib/pipes/video_tasks.py +++ b/ibllib/pipes/video_tasks.py @@ -1,5 +1,6 @@ import logging import subprocess +import time import traceback from pathlib import Path @@ -610,3 +611,118 @@ def _run(self, overwrite=True, run_qc=True, plot_qc=True): self.status = -1 return output_files + + +class LightningPose(base_tasks.VideoTask): + # TODO: make one task per cam? + gpu = 1 + io_charge = 100 + level = 2 + force = True + job_size = 'large' + + env = Path.home().joinpath('Documents', 'PYTHON', 'envs', 'litpose', 'bin', 'activate') + scripts = Path.home().joinpath('Documents', 'PYTHON', 'iblscripts', 'deploy', 'serverpc', 'litpose') + + @property + def signature(self): + signature = { + 'input_files': [(f'_iblrig_{cam}Camera.raw.mp4', self.device_collection, True) for cam in self.cameras], + 'output_files': [(f'_ibl_{cam}Camera.lightningPose.pqt', 'alf', True) for cam in self.cameras] + } + + return signature + + @staticmethod + def _video_intact(file_mp4): + """Checks that the downloaded video can be opened and is not empty""" + cap = cv2.VideoCapture(str(file_mp4)) + frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) + intact = True if frame_count > 0 else False + cap.release() + return intact + + def _check_env(self): + """Check that scripts are present, env can be activated and get iblvideo version""" + assert len(list(self.scripts.rglob('run_litpose.*'))) == 2, \ + f'Scripts run_litpose.sh and run_litpose.py do not exist in {self.scripts}' + assert self.env.exists(), f"environment does not exist in assumed location {self.env}" + command2run = f"source {self.env}; python -c 'import iblvideo; print(iblvideo.__version__)'" + process = subprocess.Popen( + command2run, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + executable="/bin/bash" + ) + info, error = process.communicate() + if process.returncode != 0: + raise AssertionError(f"environment check failed\n{error.decode('utf-8')}") + version = info.decode("utf-8").strip().split('\n')[-1] + return version + + def _run(self, overwrite=True, **kwargs): + + # Gather video files + self.session_path = Path(self.session_path) + mp4_files = [ + self.session_path.joinpath(self.device_collection, f'_iblrig_{cam}Camera.raw.mp4') for cam in self.cameras + if self.session_path.joinpath(self.device_collection, f'_iblrig_{cam}Camera.raw.mp4').exists() + ] + + labels = [label_from_path(x) for x in mp4_files] + _logger.info(f'Running on {labels} videos') + + # Check the environment + self.version = self._check_env() + _logger.info(f'iblvideo version {self.version}') + + # If all results exist and overwrite is False, skip computation + expected_outputs_present, expected_outputs = self.assert_expected(self.output_files, silent=True) + if overwrite is False and expected_outputs_present is True: + actual_outputs = expected_outputs + return actual_outputs + + # Else, loop over videos + actual_outputs = [] + for label, mp4_file in zip(labels, mp4_files): + # Catch exceptions so that the other cams can still run but set status to Errored + try: + # Check that the GPU is (still) accessible + check_nvidia_driver() + # Check that the video can be loaded + if not self._video_intact(mp4_file): + _logger.error(f"Corrupt raw video file {mp4_file}") + self.status = -1 + continue + t0 = time.time() + _logger.info(f'Running Lightning Pose on {label}Camera.') + command2run = f"{self.scripts.joinpath('run_litpose.sh')} {str(self.env)} {mp4_file} {overwrite}" + _logger.info(command2run) + process = subprocess.Popen( + command2run, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + executable="/bin/bash", + ) + info, error = process.communicate() + if process.returncode != 0: + error_str = error.decode("utf-8").strip() + _logger.error(f'Lightning pose failed for {label}Camera.\n\n' + f'++++++++ Output of subprocess for debugging ++++++++\n\n' + f'{error_str}\n' + f'++++++++++++++++++++++++++++++++++++++++++++\n') + self.status = -1 + continue + else: + _logger.info(f'{label} camera took {(time.time() - t0)} seconds') + result = next(self.session_path.joinpath('alf').glob(f'_ibl_{label}Camera.lightningPose*.pqt')) + actual_outputs.append(result) + + except BaseException: + _logger.error(traceback.format_exc()) + self.status = -1 + continue + + return actual_outputs diff --git a/ibllib/qc/base.py b/ibllib/qc/base.py index 47802a114..90899ef72 100644 --- a/ibllib/qc/base.py +++ b/ibllib/qc/base.py @@ -12,10 +12,12 @@ class QC: - """A base class for data quality control""" + """A base class for data quality control.""" def __init__(self, endpoint_id, one=None, log=None, endpoint='sessions'): """ + A base class for data quality control. + :param endpoint_id: Eid for endpoint. If using sessions can also be a session path :param log: A logging.Logger instance, if None the 'ibllib' logger is used :param one: An ONE instance for fetching and setting the QC on Alyx @@ -38,15 +40,17 @@ def __init__(self, endpoint_id, one=None, log=None, endpoint='sessions'): @abstractmethod def run(self): - """Run the QC tests and return the outcome + """Run the QC tests and return the outcome. + :return: One of "CRITICAL", "FAIL", "WARNING" or "PASS" """ pass @abstractmethod def load_data(self): - """Load the data required to compute the QC - Subclasses may implement this for loading raw data + """Load the data required to compute the QC. + + Subclasses may implement this for loading raw data. """ pass @@ -85,7 +89,8 @@ def overall_outcome(outcomes: iter, agg=max) -> spec.QC: return agg(map(spec.QC.validate, outcomes)) def _set_eid_or_path(self, session_path_or_eid): - """Parse a given eID or session path + """Parse a given eID or session path. + If a session UUID is given, resolves and stores the local path and vice versa :param session_path_or_eid: A session eid or path :return: @@ -215,9 +220,7 @@ def update_extended_qc(self, data): return out def compute_outcome_from_extended_qc(self) -> str: - """ - Returns the session outcome computed from aggregating the extended QC - """ + """Return the session outcome computed from aggregating the extended QC.""" details = self.one.alyx.get(f'/{self.endpoint}/{self.eid}', clobber=True) extended_qc = details['json']['extended_qc'] if self.json else details['extended_qc'] return self.overall_outcome(v for k, v in extended_qc.items() or {} if k[0] != '_') @@ -225,6 +228,8 @@ def compute_outcome_from_extended_qc(self) -> str: def sign_off_dict(exp_dec, sign_off_categories=None): """ + Create sign off dictionary. + Creates a dict containing 'sign off' keys for each device and task protocol in the provided experiment description. diff --git a/ibllib/qc/camera.py b/ibllib/qc/camera.py index 4828b014e..ba520ab77 100644 --- a/ibllib/qc/camera.py +++ b/ibllib/qc/camera.py @@ -71,7 +71,8 @@ class CameraQC(base.QC): - """A class for computing camera QC metrics""" + """A class for computing camera QC metrics.""" + dstypes = [ '_ibl_experiment.description', '_iblrig_Camera.frameData', # Replaces the next 3 datasets @@ -123,6 +124,8 @@ class CameraQC(base.QC): def __init__(self, session_path_or_eid, camera, **kwargs): """ + Compute and view camera QC metrics. + :param session_path_or_eid: A session id or path :param camera: The camera to run QC on, if None QC is run for all three cameras :param n_samples: The number of frames to sample for the position and brightness QC @@ -173,6 +176,7 @@ def __init__(self, session_path_or_eid, camera, **kwargs): def type(self): """ Returns the camera type based on the protocol. + :return: Returns either None, 'ephys' or 'training' """ if not self._type: @@ -181,7 +185,8 @@ def type(self): return 'ephys' if 'ephys' in self._type else 'training' def load_data(self, download_data: bool = None, extract_times: bool = False, load_video: bool = True) -> None: - """Extract the data from raw data files + """Extract the data from raw data files. + Extracts all the required task data from the raw data files. Data keys: @@ -299,7 +304,11 @@ def load_data(self, download_data: bool = None, extract_times: bool = False, loa self.load_video_data() def load_video_data(self): - # Get basic properties of video + """Get basic properties of video. + + Updates the `data` property with video metadata such as length and frame count, as well as + loading some frames to perform QC on. + """ try: self.data['video'] = get_video_meta(self.video_path, one=self.one) # Sample some frames from the video file @@ -314,8 +323,11 @@ def load_video_data(self): @staticmethod def get_active_wheel_period(wheel, duration_range=(3., 20.), display=False): """ - Attempts to find a period of movement where the wheel accelerates and decelerates for - the wheel motion alignment QC. + Find period of active wheel movement. + + Attempts to find a period of movement where the wheel accelerates and decelerates for the + wheel motion alignment QC. + :param wheel: A Bunch of wheel timestamps and position data :param duration_range: The candidates must be within min/max duration range :param display: If true, plot the selected wheel movement @@ -340,13 +352,17 @@ def get_active_wheel_period(wheel, duration_range=(3., 20.), display=False): return edges[i] def ensure_required_data(self): - """ - Ensures the datasets required for QC are local. If the download_data attribute is True, - any missing data are downloaded. If all the data are not present locally at the end of - it an exception is raised. If the stream attribute is True, the video file is not - required to be local, however it must be remotely accessible. + """Ensure the datasets required for QC are local. + + If the download_data attribute is True, any missing data are downloaded. If all the data + are not present locally at the end of it an exception is raised. If the stream attribute + is True, the video file is not required to be local, however it must be remotely accessible. NB: Requires a valid instance of ONE and a valid session eid. - :return: + + Raises + ------ + AssertionError + The data requires for complete QC are not present. """ assert self.one is not None, 'ONE required to download data' @@ -443,14 +459,15 @@ def _set_sync(self, session_params=False): def _update_meta_from_session_params(self, sess_params): """ - Update the default expected video properties with those defined in the experiment - description file (if any). This updates the `video_meta` property with the fps, width and - height for the type and camera label. + Update the default expected video properties. + + Use properties defined in the experiment description file, if present. This updates the + `video_meta` property with the fps, width and height for the type and camera label. Parameters ---------- sess_params : dict - The loaded experiment.description file. + The loaded experiment description file. """ try: assert sess_params @@ -470,7 +487,8 @@ def _update_meta_from_session_params(self, sess_params): def run(self, update: bool = False, **kwargs) -> (str, dict): """ - Run video QC checks and return outcome + Run video QC checks and return outcome. + :param update: if True, updates the session QC fields on Alyx :param download_data: if True, downloads any missing data if required :param extract_times: if True, re-extracts the camera timestamps from the raw data @@ -493,7 +511,7 @@ def is_metric(x): # print(classe) checks = getmembers(self.__class__, is_metric) - checks = self.remove_check(checks) + checks = self._remove_check(checks) self.metrics = {f'_{namespace}_' + k[6:]: fn(self) for k, fn in checks} values = [x if isinstance(x, spec.QC) else x[0] for x in self.metrics.values()] @@ -508,7 +526,20 @@ def is_metric(x): self.update(outcome, namespace) return outcome, self.metrics - def remove_check(self, checks): + def _remove_check(self, checks): + """ + Remove one or more check functions from QC checklist. + + Parameters + ---------- + checks : list of tuple + The complete list of check function name and handle. + + Returns + ------- + list of tuple + The list of check function name and handle, sans names in `checks_to_remove` property. + """ if len(self.checks_to_remove) == 0: return checks else: @@ -519,9 +550,10 @@ def remove_check(self, checks): return checks def check_brightness(self, bounds=(40, 200), max_std=20, roi=True, display=False): - """Check that the video brightness is within a given range + """Check that the video brightness is within a given range. + The mean brightness of each frame must be with the bounds provided, and the standard - deviation across samples frames should be less then the given value. Assumes that the + deviation across samples frames should be less than the given value. Assumes that the frame samples are 2D (no colour channels). :param bounds: For each frame, check that: bounds[0] < M < bounds[1], @@ -584,14 +616,14 @@ def check_brightness(self, bounds=(40, 200), max_std=20, roi=True, display=False return self.overall_outcome([warn_range, fail_range]) def check_file_headers(self): - """Check reported frame rate matches FPGA frame rate""" + """Check reported frame rate matches FPGA frame rate.""" if None in (self.data['video'], self.video_meta): return spec.QC.NOT_SET expected = self.video_meta[self.type][self.label] return spec.QC.PASS if self.data['video']['fps'] == expected['fps'] else spec.QC.FAIL def check_framerate(self, threshold=1.): - """Check camera times match specified frame rate for camera + """Check camera times match specified frame rate for camera. :param threshold: The maximum absolute difference between timestamp sample rate and video frame rate. NB: Does not take into account dropped frames. @@ -603,7 +635,7 @@ def check_framerate(self, threshold=1.): return spec.QC.PASS if abs(Fs - fps) < threshold else spec.QC.FAIL, float(round(Fs, 3)) def check_pin_state(self, display=False): - """Check the pin state reflects Bpod TTLs""" + """Check the pin state reflects Bpod TTLs.""" if not data_for_keys(('video', 'pin_state', 'audio'), self.data): return spec.QC.NOT_SET size_diff = int(self.data['pin_state'].shape[0] - self.data['video']['length']) @@ -631,7 +663,7 @@ def check_pin_state(self, display=False): return outcome, ndiff_low2high, size_diff def check_dropped_frames(self, threshold=.1): - """Check how many frames were reported missing + """Check how many frames were reported missing. :param threshold: The maximum allowable percentage of dropped frames """ @@ -654,7 +686,7 @@ def check_dropped_frames(self, threshold=.1): return outcome, int(sum(dropped)), size_diff def check_timestamps(self): - """Check that the camera.times array is reasonable""" + """Check that the camera.times array is reasonable.""" if not data_for_keys(('timestamps', 'video'), self.data): return spec.QC.NOT_SET # Check number of timestamps matches video @@ -666,7 +698,7 @@ def check_timestamps(self): return spec.QC.PASS if increasing and length_matches and nanless else spec.QC.FAIL def check_camera_times(self): - """Check that the number of raw camera timestamps matches the number of video frames""" + """Check that the number of raw camera timestamps matches the number of video frames.""" if not data_for_keys(('bonsai_times', 'video'), self.data): return spec.QC.NOT_SET length_match = len(self.data['camera_times']) == self.data['video'].length @@ -675,7 +707,7 @@ def check_camera_times(self): return outcome, len(self.data['camera_times']) - self.data['video'].length def check_resolution(self): - """Check that the timestamps and video file resolution match what we expect""" + """Check that the timestamps and video file resolution match what we expect.""" if self.data['video'] is None: return spec.QC.NOT_SET actual = self.data['video'] @@ -684,7 +716,7 @@ def check_resolution(self): return spec.QC.PASS if match else spec.QC.FAIL def check_wheel_alignment(self, tolerance=(1, 2), display=False): - """Check wheel motion in video correlates with the rotary encoder signal + """Check wheel motion in video correlates with the rotary encoder signal. Check is skipped for body camera videos as the wheel is often obstructed @@ -750,7 +782,8 @@ def check_wheel_alignment(self, tolerance=(1, 2), display=False): def check_position(self, hist_thresh=(75, 80), pos_thresh=(10, 15), metric=cv2.TM_CCOEFF_NORMED, display=False, test=False, roi=None, pct_thresh=True): - """Check camera is positioned correctly + """Check camera is positioned correctly. + For the template matching zero-normalized cross-correlation (default) should be more robust to exposure (which we're not checking here). The L2 norm (TM_SQDIFF) should also work. @@ -859,7 +892,8 @@ def check_position(self, hist_thresh=(75, 80), pos_thresh=(10, 15), def check_focus(self, n=20, threshold=(100, 6), roi=False, display=False, test=False, equalize=True): - """Check video is in focus + """Check video is in focus. + Two methods are used here: Looking at the high frequencies with a DFT and applying a Laplacian HPF and looking at the variance. @@ -873,17 +907,29 @@ def check_focus(self, n=20, threshold=(100, 6), - Focus check thrown off by brightness. This may be fixed by equalizing the histogram (set equalize=True) - :param n: number of frames from frame_samples data to use in check. - :param threshold: the lower boundary for Laplacian variance and mean FFT filtered - brightness, respectively - :param roi: if False, the roi is determined via template matching for the face or body. - If None, some set ROIs for face and paws are used. A list of slices may also be passed. - :param display: if true, the results are displayed - :param test: if true, a set of artificially blurred reference frames are used as the - input. This can be used to selecting reasonable thresholds. - :param equalize: if true, the histograms of the frames are equalized, resulting in an - increased the global contrast and linear CDF. This makes check robust to low light - conditions. + Parameters + ---------- + n : int + Number of frames from frame_samples data to use in check. + threshold : tuple of float + The lower boundary for Laplacian variance and mean FFT filtered brightness, + respectively. + roi : bool, None, list of slice + If False, the roi is determined via template matching for the face or body. + If None, some set ROIs for face and paws are used. A list of slices may also be passed. + display : bool + If true, the results are displayed. + test : bool + If true, a set of artificially blurred reference frames are used as the input. This can + be used to selecting reasonable thresholds. + equalize : bool + If true, the histograms of the frames are equalized, resulting in an increased the + global contrast and linear CDF. This makes check robust to low light conditions. + + Returns + ------- + one.spec.QC + The QC outcome, either FAIL or PASS. """ no_frames = self.data['frame_samples'] is None or len(self.data['frame_samples']) == 0 if not test and no_frames: @@ -995,7 +1041,8 @@ def check_focus(self, n=20, threshold=(100, 6), return spec.QC.PASS if passes else spec.QC.FAIL def find_face(self, roi=None, test=False, metric=cv2.TM_CCOEFF_NORMED, refs=None): - """Use template matching to find face location in frame + """Use template matching to find face location in frame. + For the template matching zero-normalized cross-correlation (default) should be more robust to exposure (which we're not checking here). The L2 norm (TM_SQDIFF) should also work. That said, normalizing the histograms works best. @@ -1028,7 +1075,7 @@ def find_face(self, roi=None, test=False, metric=cv2.TM_CCOEFF_NORMED, refs=None @staticmethod def load_reference_frames(side): - """Load some reference frames for a given video + """Load some reference frames for a given video. The reference frames are from sessions where the camera was well positioned. The frames are in qc/reference, one file per camera, only one channel per frame. The @@ -1043,7 +1090,7 @@ def load_reference_frames(side): @staticmethod def imshow(frame, ax=None, title=None, **kwargs): - """plt.imshow with some convenient defaults for greyscale frames""" + """plt.imshow with some convenient defaults for greyscale frames.""" h = ax or plt.gca() defaults = { 'cmap': kwargs.pop('cmap', 'gray'), @@ -1057,8 +1104,13 @@ def imshow(frame, ax=None, title=None, **kwargs): class CameraQCCamlog(CameraQC): - """A class for computing camera QC metrics from camlog data. For this QC we expect the check_pin_state to be NOT_SET as we are - not using the GPIO for timestamp alignment""" + """ + A class for computing camera QC metrics from camlog data. + + For this QC we expect the check_pin_state to be NOT_SET as we are not using the GPIO for + timestamp alignment. + """ + dstypes = [ '_iblrig_taskData.raw', '_iblrig_taskSettings.raw', @@ -1076,13 +1128,19 @@ class CameraQCCamlog(CameraQC): ] def __init__(self, session_path_or_eid, camera, sync_collection='raw_sync_data', sync_type='nidq', **kwargs): + """Compute camera QC metrics from camlog data. + + For this QC we expect the check_pin_state to be NOT_SET as we are not using the GPIO for + timestamp alignment. + """ super().__init__(session_path_or_eid, camera, sync_collection=sync_collection, sync_type=sync_type, **kwargs) self._type = 'ephys' self.checks_to_remove = ['check_pin_state'] def load_data(self, download_data: bool = None, extract_times: bool = False, load_video: bool = True, **kwargs) -> None: - """Extract the data from raw data files + """Extract the data from raw data files. + Extracts all the required task data from the raw data files. Data keys: @@ -1178,13 +1236,12 @@ def load_data(self, download_data: bool = None, self.load_video_data() def ensure_required_data(self): - """ - Ensures the datasets required for QC are local. If the download_data attribute is True, - any missing data are downloaded. If all the data are not present locally at the end of - it an exception is raised. If the stream attribute is True, the video file is not - required to be local, however it must be remotely accessible. + """Ensure the datasets required for QC are local. + + If the download_data attribute is True, any missing data are downloaded. If all the data + are not present locally at the end of it an exception is raised. If the stream attribute + is True, the video file is not required to be local, however it must be remotely accessible. NB: Requires a valid instance of ONE and a valid session eid. - :return: """ assert self.one is not None, 'ONE required to download data' @@ -1229,7 +1286,7 @@ def ensure_required_data(self): assert all_present or not required, f'Dataset {dstype} not found' def check_camera_times(self): - """Check that the number of raw camera timestamps matches the number of video frames""" + """Check that the number of raw camera timestamps matches the number of video frames.""" if not data_for_keys(('camera_times', 'video'), self.data): return spec.QC.NOT_SET length_match = len(self.data['camera_times']) == self.data['video'].length @@ -1239,12 +1296,14 @@ def check_camera_times(self): def data_for_keys(keys, data): - """Check keys exist in 'data' dict and contain values other than None""" + """Check keys exist in 'data' dict and contain values other than None.""" return data is not None and all(k in data and data.get(k, None) is not None for k in keys) def get_task_collection(sess_params): """ + Return the first non-passive task collection. + Returns the first task collection from the experiment description whose task name does not contain 'passive', otherwise returns 'raw_behavior_data'. @@ -1265,7 +1324,7 @@ def get_task_collection(sess_params): def get_video_collection(sess_params, label): """ - Returns the collection containing the raw video data for a given camera. + Return the collection containing the raw video data for a given camera. Parameters ---------- @@ -1289,8 +1348,10 @@ def get_video_collection(sess_params, label): def run_all_qc(session, cameras=('left', 'right', 'body'), **kwargs): - """Run QC for all cameras + """Run QC for all cameras. + Run the camera QC for left, right and body cameras. + :param session: A session path or eid. :param update: If True, QC fields are updated on Alyx. :param cameras: A list of camera names to perform QC on. diff --git a/release_notes.md b/release_notes.md index 83f2bbd59..49fec5713 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,3 +1,12 @@ +## Release Note 2.35.0 + +## features +- Adding LightningPose task + +## bugfixes +- SessionLoader can now handle trials that are not in the alf collection +- Extraction of trials from pre-generated sequences supports iblrigv8 keys + ## Release Note 2.34.0 ## features @@ -8,6 +17,11 @@ - Typo in raw_ephys_data documentation - oneibl.register_datasets accounts for non existing sessions when checking protected dsets +#### 2.34.1 +- Ensure mesoscope frame QC files are sorted before concatenating +- Look for SESSION_TEMPLATE_ID key of task settings for extraction of pre-generated choice world sequences +- Download required ap.meta files when building pipeline for task_qc command + ## Release Note 2.33.0 ## features