From 8880877137ec822be9e8be1869e7533198696c57 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Thu, 29 Jan 2026 08:49:45 -0800 Subject: [PATCH 1/7] Removal of extraneous classes and couchdb mechanisms, updates to gaze loading in Session class --- environment.yml | 12 +- test_data_base.py | 18 -- vedb_store/__init__.py | 20 +- vedb_store/dbwrapper.py | 32 --- vedb_store/defaults.cfg | 11 - vedb_store/orm/calibration.py | 110 ---------- vedb_store/orm/gaze.py | 238 -------------------- vedb_store/orm/mappedclass.py | 280 ------------------------ vedb_store/orm/marker_detection.py | 139 ------------ vedb_store/orm/paramdictionary.py | 150 ------------- vedb_store/orm/pupil_detection.py | 150 ------------- vedb_store/orm/recording.py | 291 ------------------------- vedb_store/orm/segment.py | 53 ----- vedb_store/orm/session.py | 339 +++++++++++++++++++---------- vedb_store/orm/subject.py | 62 ------ vedb_store/utils.py | 21 +- 16 files changed, 241 insertions(+), 1685 deletions(-) delete mode 100644 test_data_base.py delete mode 100644 vedb_store/dbwrapper.py delete mode 100644 vedb_store/orm/calibration.py delete mode 100644 vedb_store/orm/gaze.py delete mode 100644 vedb_store/orm/mappedclass.py delete mode 100644 vedb_store/orm/marker_detection.py delete mode 100644 vedb_store/orm/paramdictionary.py delete mode 100644 vedb_store/orm/pupil_detection.py delete mode 100644 vedb_store/orm/recording.py delete mode 100644 vedb_store/orm/segment.py delete mode 100644 vedb_store/orm/subject.py diff --git a/environment.yml b/environment.yml index d5c54ec..80464ed 100644 --- a/environment.yml +++ b/environment.yml @@ -1,11 +1,11 @@ -name: vedb_store +name: vedb_analysis channels: - anaconda - conda-forge - auto - vedb dependencies: - - python=3.8 + - python=3.10 - numpy - scipy - matplotlib @@ -20,9 +20,9 @@ dependencies: - appdirs - python-couchdb - opencv=4.2.0 - - msgpack-python=0.6.2 + - msgpack-python - pip - pip: - - git+https://github.com/vedb/file_io@main - - git+https://github.com/vedb/docdb_lite@main - - git+https://github.com/vedb/vedb-store@devel + - git+https://github.com/piecesofmindlab/file_io@main + - git+https://github.com/vedb/vedb-gaze@main + - git+https://github.com/vedb/vedb-store@main diff --git a/test_data_base.py b/test_data_base.py deleted file mode 100644 index 21adde9..0000000 --- a/test_data_base.py +++ /dev/null @@ -1,18 +0,0 @@ - -from data_base_tools.session import Session -#from data_base_tools.recorded_data import RecordedData -from data_base_tools.subject import Subject -import numpy as np -import cv2 - -print("Hello!!!") - -main_directory = '/hdd01/kamran_sync/vedb/recordings_pilot/pilot_study_1/' -session_id = '423/' -my_session = Session(session_id, main_directory, move = False, export_directory = '/000/') - -print('created session for: ',my_session.session_id) - -print('export directory: ', my_session.export_directory) - -my_session.from_pupil() \ No newline at end of file diff --git a/vedb_store/__init__.py b/vedb_store/__init__.py index da7dd3a..a0a983f 100644 --- a/vedb_store/__init__.py +++ b/vedb_store/__init__.py @@ -1,21 +1,3 @@ from . import orm, options -from .orm.session import Session, SessionClip -from .orm.recording import RecordingSystem, RecordingDevice, Camera, Odometer, GPS -from .orm.segment import Segment -from .orm.subject import Subject -from .orm.paramdictionary import ParamDictionary -from .orm.pupil_detection import PupilDetection -from .orm.marker_detection import MarkerDetection -from .orm.calibration import Calibration -from .orm.gaze import Gaze, GazeError - +from .orm.session import Session, SessionClip, ClipList from functools import partial - -try: - from .dbwrapper import docdb_lite as docdb - dbhost = options.config.get('db', 'dbhost') - dbname = options.config.get('db', 'dbname') - if (dbname is not None) and (dbname.lower() not in ('none', '')): - docdb.getclient = partial(docdb.getclient, dbhost=dbhost, dbname=dbname) -except ImportError: - print('Failed to import docdb - no database functions available.') diff --git a/vedb_store/dbwrapper.py b/vedb_store/dbwrapper.py deleted file mode 100644 index d6cfd58..0000000 --- a/vedb_store/dbwrapper.py +++ /dev/null @@ -1,32 +0,0 @@ -# Wrapper for docdb classes -from .orm.session import Session #, Labels? (subclass Stimulus to allow for non-image/auditory/whatever inputs?) -from .orm.recording import RecordingSystem, RecordingDevice, Camera, Odometer, GPS -from .orm.segment import Segment -from .orm.subject import Subject -from .orm.paramdictionary import ParamDictionary -from .orm.pupil_detection import PupilDetection -from .orm.marker_detection import MarkerDetection -from .orm.calibration import Calibration -from .orm.gaze import Gaze, GazeError - -try: - import docdb_lite - docdb_lite.is_verbose = False - docdb_lite.orm.class_type.update( - Session=Session, - Segment=Segment, - RecordingSystem=RecordingSystem, - RecordingDevice=RecordingDevice, - Camera=Camera, - GPS=GPS, - Odometer=Odometer, - Subject=Subject, - ParamDictionary=ParamDictionary, - PupilDetection=PupilDetection, - MarkerDetection=MarkerDetection, - Calibration=Calibration, - Gaze=Gaze, - GazeError=GazeError, - ) -except ImportError: - print("Could not initialize classes in docdb database") diff --git a/vedb_store/defaults.cfg b/vedb_store/defaults.cfg index 8863741..5b53a36 100644 --- a/vedb_store/defaults.cfg +++ b/vedb_store/defaults.cfg @@ -6,14 +6,3 @@ vedb_directory=~/remote_mounts/vedbcloud0/vedb_official/ sync_directory= # Directory where processed gaze and odometry files are stored: proc_directory=~/remote_mounts/vedbcloud0/db/pydra_cache/ - -[db] -# Deprecated and will probably go away -dbhost=None -dbname=None - -[param_defaults] -# Deprecated and will probably go away -uvc={"codec": "libx264", "color_format": "gray", "crf": 18, "fps": 120, "preset": "ultrafast", "resolution": [400, 400]} -flir={"codec": "libx264", "color_format": "bayer_rggb8", "crf": 18, "fps": 30, "preset": "ultrafast", "resolution": [2048, 1536], "settings": {"ExposureAuto": "Continuous", "GainAuto": "Continuous"},} -t265={"codec": "libx264", "color_format": "rgb", "crf": 18, "fps": 30, "preset": "ultrafast", "resolution": [1696, 800]} diff --git a/vedb_store/orm/calibration.py b/vedb_store/orm/calibration.py deleted file mode 100644 index de33c57..0000000 --- a/vedb_store/orm/calibration.py +++ /dev/null @@ -1,110 +0,0 @@ -from .mappedclass import MappedClass -from .. import options, utils -import numpy as np -import copy -import os - -BASE_PATH = options.config.get('paths', 'proc_directory') - -class Calibration(MappedClass): - def __init__(self, - type='Calibration', - calibration_class='vedb_gaze.calibration.Calibration', - session=None, - pupil_detection=None, - marker_detection=None, - eye=None, - epoch=None, - failed=None, - params=None, - data=None, - tag=None, - fname=None, - dbi=None, - _id=None, - _rev=None): - """Computed calibration for from a session""" - inpt = locals() - self.type = 'Calibration' - computed_defaults = ['data', 'fname', 'path'] - for k, v in inpt.items(): - if k in computed_defaults: - setattr(self, '_' + k, v) - elif not k in ['self', 'type',]: - setattr(self, k, v) - self._path = None - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = ['data'] - # Constructed on the fly and not saved to docdict - self._temp_fields = ['path', 'calibration'] - # Fields that are other database objects - self._db_fields = ['session', 'pupil_detection', 'marker_detection', 'params'] - - def load(self,): - """Load computed calibration parameters - - Parameters - ---------- - type : str - 'arraydict', 'dictlist', or 'dataframe' - - """ - # Fill fields that are database objects - self.db_load() - # Manage output type. Archival data is a dictionary of arrays; convert to desired output - calibration_class = utils.get_function(self.calibration_class) - self.calibration = calibration_class.load(self.fpath) - - def save(self, is_overwrite=False): - if self.data is None: - raise ValueError("Can't save without computed calibration data!") - if self.dbi is None: - raise ValueError("dbi (database interface) field must be specified to save object!") - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception( - "Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - if not os.path.exists(self.path): - gaze_path = os.path.join(BASE_PATH, 'gaze') - if not os.path.exists(gaze_path): - raise ValueError(f"Base path for processed gaze ({gaze_path}) not found!") - else: - print('Creating folder %s...'%self.path) - os.makedirs(self.path) - np.savez(doc.fpath, **self.data) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - @property - def data(self): - if self._data is None: - if os.path.exists(self.fpath): - self._data=dict(np.load(self.fpath, allow_pickle=True)) - else: - raise ValueError("No data loaded or found") - return self._data - - @property - def fname(self): - if self._fname is None: - self.db_load() - if not np.any([x is None for x in [self._id, self.params, self.eye]]): - self._fname = 'calibration-{}-{}-{}.npz'.format( - self.params.fn.split('.')[-1], - self.params.tag, - self._id) - return self._fname - - @property - def path(self): - if self._path is None: - self.db_load() - if self.session is not None: - self._path = os.path.join(BASE_PATH, 'gaze', self.session.folder) - return self._path diff --git a/vedb_store/orm/gaze.py b/vedb_store/orm/gaze.py deleted file mode 100644 index 7da86e4..0000000 --- a/vedb_store/orm/gaze.py +++ /dev/null @@ -1,238 +0,0 @@ -from .mappedclass import MappedClass -from .. import options, utils -import numpy as np -import copy -import os - -BASE_PATH = options.config.get('paths', 'proc_directory') - -class Gaze(MappedClass): - def __init__(self, - type='Gaze', - data=None, - session=None, - pupil_detection=None, - calibration=None, - calibration_epoch=None, - eye=None, - failed=None, - fname=None, - params=None, - tag=None, - dbi=None, - _id=None, - _rev=None): - """Segment of data from a session""" - inpt = locals() - self.type = 'Gaze' - computed_defaults = ['data', 'fname', 'path'] - for k, v in inpt.items(): - if k in computed_defaults: - setattr(self, '_' + k, v) - elif not k in ['self', 'type', ]: - setattr(self, k, v) - self._path = None - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = ['data'] - # Constructed on the fly and not saved to docdict - self._temp_fields = ['timestamp', 'path'] - # Fields that are other database objects - self._db_fields = ['session', 'params', 'calibration', 'pupil_detection'] - # Placeholder for computed timestamps - self._timestamp = None - - def load(self, type='arraydict'): - """Load labeled gaze points - - Parameters - ---------- - type : str - 'arraydict', 'dictlist', or 'dataframe' - - """ - # Fill fields that are database objects - self.db_load() - # Manage output type. Archival data is a dictionary of arrays; convert to desired output - if type == 'arraydict': - return copy.deepcopy(self.data) - elif type == 'dictlist': - return utils.arraydict_to_dictlist(self.data) - elif type == 'dataframe': - return utils.arraydict_to_dataframe(self.data, mapping=utils.mapping_pupil_to_df) - - def save(self, is_overwrite=False): - if self.data is None: - raise ValueError("Can't save without data!") - if self.dbi is None: - raise ValueError( - "dbi (database interface) field must be specified to save object!") - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception( - "Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - if not os.path.exists(self.path): - gaze_path = os.path.join(BASE_PATH, 'gaze') - if not os.path.exists(gaze_path): - raise ValueError(f"Base path for processed gaze ({gaze_path}) not found!") - else: - print('Creating folder %s...' % self.path) - os.makedirs(self.path) - np.savez(doc.fpath, **self.data) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - @property - def timestamp(self): - self.db_load() - if self._timestamp is None: - self._timestamp = self.data['timestamp'] - self.session.start_time - return self._timestamp - - @property - def data(self): - if self._data is None: - if os.path.exists(self.fpath): - self._data = dict(np.load(self.fpath, allow_pickle=-True)) - else: - raise ValueError("No data loaded or found") - return self._data - - @property - def fname(self): - if self._fname is None: - self.db_load() - if not np.any([x is None for x in [self._id, self.params, self.eye]]): - self._fname = 'gaze-{}-{}-{}-{}.npz'.format( - self.eye, - self.params.fn.split('.')[-1], - self.params.tag, - self._id) - return self._fname - - @property - def path(self): - if self._path is None: - self.db_load() - if self.session is not None: - self._path = os.path.join(BASE_PATH, 'gaze', self.session.folder) - return self._path - -# WORKING HERE. -class GazeError(MappedClass): - def __init__(self, - type='GazeError', - data=None, - session=None, - gaze=None, - marker_detection=None, - eye=None, - epoch=None, - failed=None, - fname=None, - params=None, - tag=None, - dbi=None, - _id=None, - _rev=None): - """Error estimate for a given (usually validation) marker detection""" - inpt = locals() - self.type = 'GazeError' - computed_defaults = ['data', 'fname', 'path'] - for k, v in inpt.items(): - if k in computed_defaults: - setattr(self, '_' + k, v) - elif not k in ['self', 'type', ]: - setattr(self, k, v) - self._path = None - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = ['data'] - # Constructed on the fly and not saved to docdict - self._temp_fields = ['path'] - # Fields that are other database objects - self._db_fields = ['session', 'params', 'gaze', 'marker_detection'] - - def load(self, type='arraydict'): - """Load labeled gaze points - - Parameters - ---------- - type : str - 'arraydict', 'dictlist', or 'dataframe' - - """ - # Fill fields that are database objects - self.db_load() - # Manage output type. Archival data is a dictionary of arrays; convert to desired output - if type == 'arraydict': - return copy.deepcopy(self.data) - elif type == 'dictlist': - return utils.arraydict_to_dictlist(self.data) - elif type == 'dataframe': - raise NotImplementedError('Not yet!') - # Mapping is likely to be different than for other types for which - # mappings exist; look into this if this functionality is needed, - # may axe this option. - return utils.arraydict_to_dataframe(self.data, mapping=utils.mapping_pupil_to_df) - - def save(self, is_overwrite=False): - if self.data is None: - raise ValueError("Can't save without data!") - if self.dbi is None: - raise ValueError( - "dbi (database interface) field must be specified to save object!") - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception( - "Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - if not os.path.exists(self.path): - gaze_path = os.path.join(BASE_PATH, 'gaze') - if not os.path.exists(gaze_path): - raise ValueError(f"Base path for processed gaze ({gaze_path}) not found!") - else: - print('Creating folder %s...' % self.path) - os.makedirs(self.path) - np.savez(doc.fpath, **self.data) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - @property - def data(self): - if self._data is None: - if os.path.exists(self.fpath): - self._data = dict(np.load(self.fpath, allow_pickle=-True)) - else: - raise ValueError("No data loaded or found") - return self._data - - @property - def fname(self): - if self._fname is None: - self.db_load() - if not np.any([x is None for x in [self._id, self.params, self.eye]]): - self._fname = 'gaze_error-{}-epoch{:02d}-{}-{}-{}.npz'.format( - self.eye, - self.epoch, - self.params.fn.split('.')[-1], - self.params.tag, - self._id) - return self._fname - - @property - def path(self): - if self._path is None: - self.db_load() - if self.session is not None: - self._path = os.path.join(BASE_PATH, 'gaze', self.session.folder) - return self._path diff --git a/vedb_store/orm/mappedclass.py b/vedb_store/orm/mappedclass.py deleted file mode 100644 index 1d278e4..0000000 --- a/vedb_store/orm/mappedclass.py +++ /dev/null @@ -1,280 +0,0 @@ -# General database class for all vedb_store objects -import os -import six -import json -import warnings -import pathlib -import file_io as fio -from ..options import config - - -def _obj2id_strlist(value): - if isinstance(value, MappedClass): - # _id should always be top-level - out = value._id - elif isinstance(value, (list, tuple)): - out = [_obj2id_strlist(v) for v in value] - elif isinstance(value, dict): - out = _obj2id_doc(value) - else: - out = value - return out - -def _obj2id_doc(doc): - """Map all database-mappable objects in a document (or dictionary) to database _ids - - searches through fields of a dict for strings, lists, or dictionaries in which - the values contain MappedClass objects - """ - out = {} - for k, v in doc.items(): - if k[0] == '_' and not k in ('_id', '_rev'): - # Avoid any keys in dict with "_" prefix - continue - else: - if isinstance(v, (MappedClass, list, tuple)): - # Separate function for lists / tuples - out[k] = _obj2id_strlist(v) - elif isinstance(v, dict): - # Recursive call for dicts - out[k] = _obj2id_doc(v) - else: - # For strings & anything but lists, tuples, and dicts, leave it alone - out[k] = v - return out - -def _id2obj_strlist(value, dbi): - vb = dbi.is_verbose - dbi.is_verbose = False - v = value - if isinstance(value, str): - if value in ('None', 'multi_component'): - v = value - else: - v = dbi.query(1, _id=value, return_objects=True) - elif isinstance(value, (list, tuple)): - if len(value)>0: - if isinstance(value[0], MappedClass): - # already loaded - pass - else: - v = [dbi.query(1, _id=vv, return_objects=True) for vv in value] - dbi.is_verbose = vb - return v - - -class MappedClass(object): - - @property - def docdict(self): - return self._get_docdict() - - @property - def datadict(self): - return self._get_datadict() - - @property - def fpath(self): - if hasattr(self, 'path') and hasattr(self, 'fname'): - if (self.path) is None or (self.fname is None): - return None - else: - # # Deal with sync path - path = self._resolve_sync_dir(self.path) - if isinstance(self.fname, (list, tuple)): - # Multi-file input. Get list of paths for all files. - return [os.path.join(path, f) for f in self.fname] - else: - return os.path.join(path, self.fname) - else: - return None - - # @property - # def _fname(self): - # """Default fname, overwrite in child classes if you want a real file name (e.g. <_id>.hdf)""" - # return None - - def _resolve_sync_dir(self, path): - if isinstance(path, pathlib.Path): - path = str(path) - # Deal with sync path - sync_dir = config.get('paths', 'sync_directory') - if sync_dir != '': - # Sync dir is defined - old, new = sync_dir.split(',') - #print('Swapping out {} for {}'.format(old, new)) - path_out = path.replace(old, new) - path_out = os.path.expanduser(path_out) - # Make sure it's present - if not os.path.exists(path_out): - # This has proven really annoying, silencing. - # Best to do with some flag, but that's too complicated - # for now. - #warnings.warn('No sync dir found ({})'.format(path_out)) - path_out = os.path.expanduser(path) - else: - path_out = os.path.expanduser(path) - return pathlib.Path(path_out) - - def _get_docdict(self, rm_fields=()): - """Get database header dictionary representation of this object - - Used to insert this object into a database or query a database for - the existence of this object. Maintains the option to remove some - fields. - """ - # Remove fields that are never supposed to be saved in database - _to_remove = ('docdict', 'datadict', 'fpath', 'dbi', 'data') - attrs = [k for k in dir(self) if (not k[:2]=='__') and (not k in _to_remove)] - attrs = [k for k in attrs if not callable(getattr(self, k))] - # Do not save attributes with a value of None - no_value = [k for k in attrs if getattr(self, k) is None] - # Exclusion criteria - to_remove = list(rm_fields) + no_value + self._data_fields + self._temp_fields - # Get attribtues - d = dict(((k, getattr(self, k)) for k in attrs if not k in to_remove)) - # Replace all classes in document with IDs - d = _obj2id_doc(d) - # Convert to json and back to avoid unpredictable behavior due to conversion, e.g. tuple!=list - d = json.loads(json.dumps(d)) - return d - - def _get_stripped_docdict(self): - dd = self._get_docdict() - for key in ['fname', 'path', '_id', '_rev']: - if key in dd: - dd[key] = None - return dd - - def _get_datadict(self, fields=None): - if fields is None: - fields = self._data_fields - for f in fields: - if hasattr(self, 'extras') and f in self.extras: - raise Exception("There should be no data fields in 'extras' attribute!") - dd = dict((k, self[k]) for k in fields if hasattr(self, k) and self[k] is not None) - return dd - - def db_load(self, recursive=True): - """Load all attributes that are database-mapped objects objects from database. - """ - # (All of these fields better be populated by string database IDs) - for dbf in self._db_fields: - v = getattr(self, dbf) - if isinstance(v, (str, list, tuple)): - v = _id2obj_strlist(v, self.dbi) - elif v is None: - pass - elif isinstance(v, MappedClass): - pass - else: - raise ValueError("You have a value in one of fields that is expected to be a db class that is NOT a dbclass or an ID or anything sensible. Noodle brain.") - setattr(self, dbf, v) - if recursive: - for dbf in self._db_fields: - if self[dbf] is None: - # Allow missing database fields - continue - if isinstance(self[dbf], (list, tuple)): - for item in self[dbf]: - item.db_load(recursive=recursive) - else: - self[dbf].db_load(recursive=recursive) - self._dbobjects_loaded = True - - def db_fill(self, skip_fields=('date_run', 'last_updated'), allow_multiple=False): - """Check database for identical instance. - - Parameters - ---------- - skip_fields : list or tuple - fields to ignore in database check - - Returns - ------- - docs : list of db dict(s) - - """ - assert (not isinstance(self.dbi, dict)) and (not self.dbi is None) - # Search for extant db object - doc = self.docdict - date_run = {} - for sf in skip_fields: - if sf in doc: - if sf=='date_run': - date_run[sf] = doc.pop(sf) - else: - _ = doc.pop(sf) - chk = self.dbi.query_documents(**doc) - if len(chk)==0: - # Add date_run back in - doc.update(date_run) - return self.from_docdict(doc, self.dbi) - elif len(chk)==1: - # Fill doc with fields from chk - doc.update(chk[0]) - # Add date_run back in, since we're now over-writing - doc.update(date_run) - return self.from_docdict(doc, self.dbi) - else: - # TODO: Add optional search narrowing here - if allow_multiple: - return [self.from_docdict(d, self.dbi) for d in doc] - else: - raise ValueError("Database object is not uniquely specified") - - def save(self, sdir=None, is_overwrite=False, data_transform=None, data_folder=None): - """Save the contents of this object to database - - Auto-generates a unique path in sdir (random uuid string) - - NOTE - ---- - We will need to deal with commented-out sections below when it comes time to save features - """ - # Initial checks - assert self.dbi is not None, 'Must have database interface (`dbi`) property set in order to save!' - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception("Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - if len(self.datadict) > 0: - fio.save_arrays(doc.fpath, meta=doc.docdict, **self.datadict) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - def delete(self): - assert (not isinstance(self.dbi, dict)) and (not self.dbi is None) - if hasattr(self, 'path') and hasattr(self, 'fname'): - if isinstance(self.fpath, (list, tuple)): - # Leave listed files intact - pass - else: - print('Deleting %s'%self.fpath) - if not fio.fexists(self.fpath): - raise Exception("Path to real file not found") - fio.delete(self.fpath) - else: - raise Exception("Path to real file not found!") - doc = self.dbi.db[self._id] - self.dbi.db.delete(doc) - - @classmethod - def from_docdict(cls, docdict, dbinterface): - """Creates a new instance of this class from the given `docdict`. - """ - ob = cls.__new__(cls) - ob.__init__(dbi=dbinterface, **docdict) - return ob - - ### --- Housekeeping --- ### - def __getitem__(self, x): - return getattr(self, x) - - def __setitem__(self, x, y): - return setattr(self, x, y) - diff --git a/vedb_store/orm/marker_detection.py b/vedb_store/orm/marker_detection.py deleted file mode 100644 index 75e2bbc..0000000 --- a/vedb_store/orm/marker_detection.py +++ /dev/null @@ -1,139 +0,0 @@ -from .mappedclass import MappedClass -from .. import options, utils -import numpy as np -import os - -BASE_PATH = options.config.get('paths', 'proc_directory') - -class MarkerDetection(MappedClass): - def __init__(self, - type='MarkerDetection', - data=None, - session=None, - marker_type=None, - detection_params=None, - epoch_params=None, - epoch=None, - failed=None, - #epoch_overall=None, # TO DO. - fname=None, - tag=None, - dbi=None, - _id=None, - _rev=None): - """Detected markers for a session""" - inpt = locals() - self.type = 'MarkerDetection' - computed_defaults = ['data', 'fname', ] # , 'epoch_overall'] - for k, v in inpt.items(): - if k in computed_defaults: - setattr(self, '_' + k, v) - elif not k in ['self', 'type',]: - setattr(self, k, v) - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = ['data'] - # Constructed on the fly and not saved to docdict - self._temp_fields = ['timestamp', 'path'] - # Fields that are other database objects - self._db_fields = ['session', 'detection_params', 'epoch_params'] - # Placeholder for computed timestamps - self._path = None - self._timestamp = None - - def load(self, type='arraydict'): - """Load labeled pupils - - Parameters - ---------- - type : str - 'arraydict', 'dictlist', or 'dataframe' - - """ - # Fill fields that are database objects - self.db_load() - # Manage output type. Archival data is a dictionary of arrays; convert to desired output - if type == 'arraydict': - return self.data - elif type=='dictlist': - return utils.arraydict_to_dictlist(self.data) - elif type=='dataframe': - return utils.arraydict_to_dataframe(self.data, mapping=utils.mapping_marker_to_df) - - - def save(self, is_overwrite=False): - if self.data is None: - raise ValueError("Can't save without data!") - if self.dbi is None: - raise ValueError("dbi (database interface) field must be specified to save object!") - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception( - "Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - # Make sure path exists - if not os.path.exists(doc.path): - os.mkdir(doc.path) - # Save - np.savez(doc.fpath, **self.data) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - @property - def timestamp(self): - self.db_load() - if self._timestamp is None: - self._timestamp = self.data['timestamp'] - self.session.start_time - return self._timestamp - - @property - def data(self): - if self._data is None: - if os.path.exists(self.fpath): - self._data=dict(np.load(self.fpath, allow_pickle=True)) - else: - raise ValueError("No data loaded or found") - return self._data - - @property - def fname(self): - self.db_load() - if self._fname is None: - if not np.any([x is None for x in [self._id, self.detection_params, self.marker_type]]): - epoch_str = 'all' if ((self.epoch is None) or (self.epoch == 'all')) else '%02d'%self.epoch - self._fname = 'markers-{}-{}-{}-epoch{}-{}.npz'.format( - self.marker_type, - self.detection_params.fn.split('.')[-1], - self.detection_params.tag, - epoch_str, - self._id) - return self._fname - - @property - def path(self): - if self._path is None: - self.db_load() - if self.session is not None: - self._path = os.path.join(BASE_PATH, 'gaze', self.session.folder) - return self._path - - # Complication: We'd like to compute overall order of marker epochs, - # regardless of calibration / validation (the two can be used - # interchangeably). We'd like this to be a property of the object. - # However, calibration and validation won't have the same detection - # parameters, so it will be tricky to find the right ones. Tabling - # this for later. - - # @property - # def epoch_overall(self): - # self.db_load() - # if self.dbi is None: - # return None - # epochs = self.dbi.query(type='MarkerDetection', - # session=self.session._id, - # detection_params=self.detection_params._id, - # epoch_params=self.epoch_params) \ No newline at end of file diff --git a/vedb_store/orm/paramdictionary.py b/vedb_store/orm/paramdictionary.py deleted file mode 100644 index d925374..0000000 --- a/vedb_store/orm/paramdictionary.py +++ /dev/null @@ -1,150 +0,0 @@ -# FeatureSpace class - -import numpy as np -import json -import file_io as fio -from ..options import config -from .mappedclass import MappedClass -from ..utils import _is_numeric -import os - - -class ParamDictionary(MappedClass): - """Class representation of a feature space computed of a stimulus - """ - def __init__(self, type='ParamDictionary', fn=None, data_fields=None, tag='default_params', - dbi=None, path=None, fname=None, _id=None, _rev=None, **params): - """Class for parameter dictionary. Loads to/from database. - - Parameters - ---------- - path : string - folder containing file - fname : string - file name (without absolute path) - - """ - # Internalize all inputs - self.type = 'ParamDictionary' - # Flags - self._dbobjects_loaded = False - self._data_loaded = False - # Introspection - if data_fields is None: - self._data_fields = [k for k, v in params.items() if isinstance(v, np.ndarray)] - else: - self._data_fields = data_fields - self._temp_fields = [] # ignored - self._db_fields = [] # ignored... probably? - self.dbi = dbi - self.tag = tag - self.fn = fn - self._fname = fname - self._path = path - self._id = _id - self._rev = _rev - self.params = params - # Load data fields (automatic load could prove problematic) - if self.fpath is not None: - self.load() - - def _get_docdict(self, rm_fields=()): - """Get docdb (database header) dictionary representation of this object - - NOTE: This class OVERWRITES the parent class function, since this is meant to only store - a parameter dictionary + a few other fields. - - Used to insert this object into a docdb database or query a database for - the existence of this object. - - Maintain the option to remove some fields - this will be handy for partial copies - of database objects - """ - # Cull data fields from param dict - d = dict(_id=self._id, - _rev=self._rev, - type='ParamDictionary', - tag=self.tag, - fn=self.fn, - data_fields=self.data_fields, - fname=self.fname, - ) - - no_value = [k for k in d.keys() if (getattr(self, k) is None) or (getattr(self, k)==[])] - d = dict((k, v) for k, v in d.items() if not k in no_value) - # Skip parameter values that are None (??) -> Changed to allow this - #no_value_pp = [k for k in self.params.keys() if self.params[k] is None] - skip_fields = self._data_fields + list(rm_fields) #+ no_value_pp - pp = dict((k, v) for k, v in self.params.items() if not k in skip_fields) - d.update(**pp) - d = json.loads(json.dumps(d)) - return d - - def _get_datadict(self): - dd = dict((k, self.params[k]) for k in self._data_fields) - return dd - - - def load(self, cache_dir=None): - """Load param dict into memory from docdb database - docdict will have a param_dict element in it; - that will be updated with data fields - """ - for k in self.data_fields: - self.params[k] = fio.load_array(self.fpath, k, cache_dir=cache_dir) - return self.params - - - @property - def path(self): - if self._path is None: - self._path = os.path.join(config.get('paths', 'vedb_directory'), 'processed', 'analysis_parameters') - return self._path - - @property - def fname(self): - if self._fname is None: - if not any([x is None for x in [self._id, self.fn, self.tag]]): - _id = self._id - param_tag = self.tag - fn_name = self.fn.split('.')[-1] - self._fname = f'{fn_name}-{param_tag}-{_id}.hdf' - return self._fname - - def save(self, sdir=None, is_overwrite=False): - """Save entry to database / save - """ - if sdir is not None: - self._path = sdir - if self._id is None: - self._id = self.dbi.get_uuid() - - return super(ParamDictionary, self).save(sdir=sdir, is_overwrite=is_overwrite, data_folder='Parameters') - - - @property - def data_fields(self): - return self._data_fields - - - @classmethod - def get_options(cls, fn, dbi): - function_name = '.'.join([fn.__module__, fn.__name__]) - pds = dbi.query(type='ParamDictionary', fn=function_name) - return sorted([p.tag for p in pds]) - - - ### ---- Housekeeping --- ### - def __repr__(self): - nm = '\n' - keys = ['fn', 'tag', 'params', 'path', 'fname', '_id', '_rev'] - args = () - for k in keys: - if hasattr(self, k): - to_add = repr(getattr(self, k)) - to_add = to_add.split('\n')[0] - args+=(k, to_add) - ss = '%10s : %s\n'*int(len(args)/2) - ss = ss%args - return nm+ss - diff --git a/vedb_store/orm/pupil_detection.py b/vedb_store/orm/pupil_detection.py deleted file mode 100644 index bfbbbdb..0000000 --- a/vedb_store/orm/pupil_detection.py +++ /dev/null @@ -1,150 +0,0 @@ -from .mappedclass import MappedClass -from .. import options, utils -import textwrap -import numpy as np -import copy -import os - -BASE_PATH = options.config.get('paths', 'proc_directory') - -# Handling computed features: -# For session, automatically search for and store pointers to all feature spaces that have been computed for that session? -# Perhpas OK, with lazy loading of features_available...? - -# Potential issue: there will be THOUSANDS, maybe tens or hundreds of thousands of these. Maybe don't store -# every single (start, stop) in database? Maybe allow lists of indices? -# Should indices be TIME values instead? Mappable in straightforward fashion to frames, depending on fps of desired data - -# Label here may need to be broken into multiple kwargs. e.g. if we have a set list of human activities we want to label, -# that could be one kw, and if we have eye events, that could be another...? -class PupilDetection(MappedClass): - def __init__(self, - type='PupilDetection', - data=None, - session=None, - eye=None, - failed=None, - fname=None, - params=None, - tag=None, - dbi=None, - _id=None, - _rev=None): - """Segment of data from a session""" - inpt = locals() - self.type = 'PupilDetection' - computed_defaults = ['data', 'fname', 'path'] - for k, v in inpt.items(): - if k in computed_defaults: - setattr(self, '_' + k, v) - elif not k in ['self', 'type',]: - setattr(self, k, v) - self._path = None - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = ['data'] - # Constructed on the fly and not saved to docdict - self._temp_fields = ['timestamp','path'] - # Fields that are other database objects - self._db_fields = ['session', 'params'] - # Placeholder for computed timestamps - self._timestamp = None - - def load(self, type='arraydict'): - """Load labeled pupils - - Parameters - ---------- - type : str - 'arraydict', 'dictlist', or 'dataframe' - - """ - # Fill fields that are database objects - self.db_load() - # Manage output type. Archival data is a dictionary of arrays; convert to desired output - if type == 'arraydict': - return copy.deepcopy(self.data) - elif type=='dictlist': - return utils.arraydict_to_dictlist(self.data) - elif type=='dataframe': - return utils.arraydict_to_dataframe(self.data, mapping=utils.mapping_pupil_to_df) - - - def save(self, is_overwrite=False): - if self.data is None: - raise ValueError("Can't save without data!") - if self.dbi is None: - raise ValueError("dbi (database interface) field must be specified to save object!") - # Search for extant db object - doc = self.db_fill(allow_multiple=False) - if (doc._id is not None) and (doc._id in self.dbi.db) and (not is_overwrite): - raise Exception( - "Found extant doc in database, and is_overwrite is set to False!") - # Assure path, _id - if doc._id is None: - doc._id = self.dbi.get_uuid() - if not os.path.exists(self.path): - gaze_path = os.path.join(BASE_PATH, 'gaze') - if not os.path.exists(gaze_path): - raise ValueError(f"Base path for processed gaze ({gaze_path}) not found!") - else: - print('Creating folder %s...'%self.path) - os.makedirs(self.path) - np.savez(doc.fpath, **self.data) - # Save header info to database - self.dbi.put_document(doc.docdict) - return doc - - @property - def timestamp(self): - self.db_load() - if self._timestamp is None: - self._timestamp = self.data['timestamp'] - self.session.start_time - return self._timestamp - - @property - def data(self): - if self._data is None: - if os.path.exists(self.fpath): - self._data=dict(np.load(self.fpath, allow_pickle=-True)) - else: - raise ValueError("No data loaded or found") - return self._data - - @property - def fname(self): - self.db_load() - if self._fname is None: - if not np.any([x is None for x in [self._id, self.params, self.eye]]): - self._fname = 'pupil_detection-{}-{}-{}-{}.npz'.format( - self.eye, - self.params.fn.split('.')[-1], - self.params.tag, - self._id) - return self._fname - - @property - def path(self): - if self._path is None: - self.db_load() - if self.session is not None: - self._path = os.path.join(BASE_PATH, 'gaze', self.session.folder) - return self._path - - def __repr__(self): - self.db_load() - rstr = textwrap.dedent(""" - vedb_store.PupilDetection - {d:>12s}: {date} - {t:>12s}: {tag} - {e:>12s}: {eye} - - """) - return rstr.format( - d='date', - date=self.session.folder, - t='tag', - tag=self.tag, - e='eye', - eye=self.eye, - ) diff --git a/vedb_store/orm/recording.py b/vedb_store/orm/recording.py deleted file mode 100644 index f6b0184..0000000 --- a/vedb_store/orm/recording.py +++ /dev/null @@ -1,291 +0,0 @@ -from .mappedclass import MappedClass -from .. import options -import file_io -import textwrap -import os - -class RecordingSystem(MappedClass): - def __init__(self, type='RecordingSystem', - tag=None, - world_camera=None, - eye_left=None, - eye_right=None, - tracking_camera=None, - tilt_angle=None, - rig_version=None, - gps=None, - dbi=None, - _id=None, - _rev=None): - """Class to store the components and settings of all devices used to collect the data - - Parameters - ---------- - tag : str - shorthand label for this RecordingSystem for easy retrieval - world_camera : Camera instance - world camera & settings - eye_left : Camera instance - eye camera & settings - eye_right : Camera instance - eye camera & settings - tracking_camera : Camera instance - tracking camera & settings - tilt_angle : int - angle of mount between tracking camera and world camera - gps : RecordingDevice instance - GPS recording device & settings - """ - self.tag = tag - self.dbi = dbi - self.type = 'RecordingSystem' - self.world_camera = world_camera - self.eye_left = eye_left - self.eye_right = eye_right - self.tracking_camera = tracking_camera - self.tilt_angle = tilt_angle - self.rig_version = rig_version - self.gps = gps - self._dbobjects_loaded = all([isinstance(x, MappedClass) for x in [self.world_camera, self.eye_left, self.eye_right, self.tracking_camera]]) # later: all([isinstance(self.getattr(x), MappedClass) for x in self._db_fields]) - self._id = _id - self._rev = _rev - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = ['world_camera', 'eye_left', 'eye_right', 'tracking_camera'] #, 'gps'] - def __repr__(self): - if not self._dbobjects_loaded: - try: - self.db_load() - except: - pass - try: - rstr = textwrap.dedent(""" - vedb_store.RecordingSystem (tag: {tag}) - {w:>16s}: {world} - {w_uid} - {t:>16s}: {tracking} - {t_uid} - {e0:>16s}: {eye_right} - {e0_uid} - {e1:>16s}: {eye_left} - {e1_uid} - {a:>16s}: {tilt} - {r:>16s}: {rig_version} - """)[1:] # 1: to get rid of initial newline - rstr = rstr.format( - tag=self.tag, - w='world_camera', - world=self.world_camera.tag, - w_uid=self.world_camera.device_uid, - t='tracking_camera', - tracking=self.tracking_camera.tag, - t_uid=self.tracking_camera.device_uid, - e0='eye_right', - eye_right=self.eye_right.tag, - e0_uid=self.eye_right.device_uid, - e1='eye_left', - eye_left=self.eye_left.tag, - e1_uid=self.eye_left.device_uid, - a='tilt', - tilt=self.tilt_angle, - r='rig_version', - rig_version=self.rig_version, - ) - return rstr - except: - return 'vedb_store.RecordingSystem (no database fields available)' - -class RecordingDevice(MappedClass): - def __init__(self, type='RecordingDevice', - tag=None, - manufacturer=None, - name=None, - device_uid=None, - fps=None, - dbi=None, - _id=None, - _rev=None): - """Parent class for other recording devices. Inheritance is not used yet; perhaps delete""" - inpt = locals() - self.type = 'RecordingDevice' - for k, v in inpt.items(): - if not k in ['self', 'type',]: - setattr(self, k, v) - - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = [] - - -class Camera(RecordingDevice): - def __init__(self, type='Camera', - tag=None, - manufacturer=None, - name=None, - device_uid=None, - resolution=None, - fps=None, - codec=None, - crf=None, - preset=None, - settings=None, - color_format=None, - # More camera properties here - dbi=None, - _id=None, - _rev=None): - """Camera recording device - - Parameters - ---------- - tag : str - shorthand retrieval tag to specify this object. Intended to be more human-readable - than the `_id` field, which absolutely must be unique; tag can potentially be - the same for multiple objects (tho this is not advised) - manufacturer : str - Device manufacturer name (e.g. FLIR, Intel) - name : str - Device name (e.g. 'Chameleon', 'RealSense t265') - device_uid : str - Specifies serial number for a unique device (e.g. one particular FLIR world camera) - resolution : list - [horizontal_dim, vertical_dim] of video - fps : int - Frame rate for camera (desired - note this may be an approximate frame rate - depending on other camera settings) - codec : str - Encoding used to record video - crf : str - Compression factor if h264 encoding is used - settings : dict - dict specifying exposure settings and other parameters - color_format : str - Color format of video, e.g. 'RGB24', 'BGR24', etc - dbi : str - Database interface object, necessary for saving this object and for - querying database for other objects from this object - _id : str - Unique database identifier - _rev : str - Unique revision number for this object in the database - - """ - inpt = locals() - self.type = 'Camera' - for k, v in inpt.items(): - if not k in ['self', 'type',]: - setattr(self, k, v) - - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = [] - - def __repr__(self): - rstr = textwrap.dedent(""" - vedb_store.Camera (tag: {tag}) - {manufacturer} : {id} : [{x}, {y}] : {fps} fps - """)[1:] # 1: to get rid of initial newline - return rstr.format( - tag=self.tag, - manufacturer=self.manufacturer, - id=self.device_uid, - x=self.resolution[0], - y=self.resolution[1], - fps=self.fps) - -class Odometer(RecordingDevice): - def __init__(self, type='Odometer', - tag=None, - manufacturer=None, - name=None, - device_uid=None, - dbi=None, - _id=None, - _rev=None, - # More odometer properties here; SLAM version? - ): - """Class to save odometer properties - Parameters - ---------- - tag : str - shorthand retrieval tag to specify this object. Intended to be more human-readable - than the `_id` field, which absolutely must be unique; tag can potentially be - the same for multiple objects (tho this is not advised) - manufacturer : str - Device manufacturer name (e.g. FLIR, Intel) - name : str - Device name (e.g. 'Chameleon', 'RealSense t265') - device_uid : str - Specifies serial number for a unique device (e.g. one particular FLIR world camera) - - Notes - ----- - fps does not seem to be saved / specifiable. - May want to include version of SLAM algorithm that generated data? - - """ - - inpt = locals() - self.type = 'Odometer' - for k, v in inpt.items(): - if not k in ['self', 'type',]: - setattr(self, k, v) - - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = [] - - -class GPS(RecordingDevice): - def __init__(self, type='GPS', - tag=None, - manufacturer=None, - name=None, - device_uid=None, - fps=None, - dbi=None, - _id=None, - _rev=None, - # More GPS properties here - ): - """Class to save odometer properties - Parameters - ---------- - tag : str - shorthand retrieval tag to specify this object. Intended to be more human-readable - than the `_id` field, which absolutely must be unique; tag can potentially be - the same for multiple objects (tho this is not advised) - manufacturer : str - Device manufacturer name (e.g. FLIR, Intel) - name : str - Device name (e.g. 'Chameleon', 'RealSense t265') - device_uid : str - Specifies serial number for a unique device (e.g. one particular FLIR world camera) - resolution : list - [horizontal_dim, vertical_dim] of video - fps : int - Frame rate for GPS (may be approximate; unclear) - """ - inpt = locals() - self.type = 'GPS' - for k, v in inpt.items(): - if not k in ['self', 'type',]: - setattr(self, k, v) - - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = [] - - def load(self, fpath, idx=(0, 100)): - pass diff --git a/vedb_store/orm/segment.py b/vedb_store/orm/segment.py deleted file mode 100644 index 75201e7..0000000 --- a/vedb_store/orm/segment.py +++ /dev/null @@ -1,53 +0,0 @@ -from .mappedclass import MappedClass -from .. import options -import file_io -import os - -# Handling computed features: -# For session, automatically search for and store pointers to all feature spaces that have been computed for that session? -# Perhpas OK, with lazy loading of features_available...? - -# Potential issue: there will be THOUSANDS, maybe tens or hundreds of thousands of these. Maybe don't store -# every single (start, stop) in database? Maybe allow lists of indices? -# Should indices be TIME values instead? Mappable in straightforward fashion to frames, depending on fps of desired data - -# Label here may need to be broken into multiple kwargs. e.g. if we have a set list of human activities we want to label, -# that could be one kw, and if we have eye events, that could be another...? -class Segment(MappedClass): - def __init__(self, session=None, start_time=None, end_time=None, label=None, type='Segment', _id=None, _rev=None): - """Segment of data from a session""" - inpt = locals() - self.type = 'Segment' - for k, v in inpt.items(): - if not k in ['self', 'type',]: - setattr(self, k, v) - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = ['session'] - - - - def load(self, data='world'): - """load something - - Parameters - ---------- - data : string or (class? class name?) - if a string, loads raw data indicated by the string (e.g. 'world' - for world video, 'eye_left' for left eye video, etc). - could also be a class input - class, loads - - """ - # Fill fields that are database objects - self.db_load() - data = file_io.load_array(self.session.paths[data_type], idx=(self.start_time, self.end_time)) - - @classmethod - def from_index(cls, index, session): - # Generate object from - pass diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 0a1c7ea..2e4a035 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -1,3 +1,35 @@ +"""vedb_store.orm.session +========================= + +Utilities for representing and working with VEDB recording sessions. + +This module provides classes and helpers to manage session data and +temporal clips within sessions. Main classes and functions include + +- Session + Representation of a VEDB recording session. Methods load data + streams, manage file paths, and interface with gaze pipelines. +- SessionClip + Represents a labeled time interval within a session and provides + methods to slice, sample, and visualize data for that interval. +- ClipList + Container for multiple SessionClip instances with logical + operations and conversion utilities. + +Notes +----- +Timestamps used throughout are typically zero-referenced to the +first frame of the world video (world_timestamps_0start.npy). Many +functions expect numpy arrays of timestamps and return time-indexed +slices of data. + +Examples +-------- +>>> from vedb_store.orm.session import Session +>>> s = Session.from_folder('/path/to/session') +>>> tt, frames = s.load('world_camera', time_idx=(0, 5)) +""" + from .mappedclass import MappedClass from .. import utils from .. import options @@ -15,30 +47,47 @@ BASE_PATH = pathlib.Path(options.config.get('paths', 'vedb_directory')).expanduser() PROC_PATH = pathlib.Path(options.config.get('paths', 'proc_directory')).expanduser() -SESSION_INFO = dict(np.load(BASE_PATH / 'session_info_wip.npz')) +SESSION_INFO = dict(np.load(BASE_PATH / 'session_info.npz')) import file_io -#from vedb_gaze.visualization import show_ellipse import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec from matplotlib import animation, patches, colors, gridspec + +def get_session_info(session, session_info=SESSION_INFO): + tmp = utils.arraydict_to_dictlist(session_info) + si_dict = dict((x['folder'], x) for x in tmp) + si = si_dict[session] + return si + def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): - """Show opencv ellipse in matplotlib, optionally with image underlay + """Show an OpenCV-style ellipse on a Matplotlib axis, optionally with an image underlay. Parameters ---------- ellipse : dict - dict of ellipse parameters derived from opencv, with fields: - * center: tuple (x, y) - * axes: tuple (x length, y length) - * angle: scalar, in degrees - img : array - underlay image to display - ax : matplotlib axis - axis into which to plot ellipse - kwargs : passed to matplotlib.patches.Ellipse + Ellipse parameters in OpenCV format with keys: + - 'center' : tuple of float (x, y) + - 'axes' : tuple of float (width, height) lengths of the ellipse axes + - 'angle' : float, rotation angle in degrees + img : array_like, optional + Image to display as a background under the ellipse. If ``None``, no + image is shown. Default is ``None``. + ax : matplotlib.axes.Axes, optional + Axes in which to plot. If ``None``, a new figure and axes are created. + center_color : str or tuple, optional + Color for the ellipse center marker. Default is ``'r'``. + **kwargs : dict, optional + Additional keyword arguments passed to ``matplotlib.patches.Ellipse``. + + Returns + ------- + tuple + ``(patch_h, pt_h)`` where ``patch_h`` is the :class:`matplotlib.patches.Ellipse` + instance added to the axes and ``pt_h`` is the scatter handle for the + ellipse center. """ if ax is None: fig, ax = plt.subplots() @@ -79,19 +128,6 @@ def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): ] # Pipeline defaults -# pipeline_default = dict( -# calibration_marker = 'find_concentric_circles-circles_halfres', -# calibration_split = 'split_circles', -# calibration_cluster = 'cluster_circles', -# validation_marker = 'find_checkerboards-checkerboard_halfres_%ssquares', -# validation_split = 'split_checkerboards', -# validation_cluster = 'cluster_checkerboards', -# pupil = 'pylids_eyelids_pupils_v2', -# pupil_detrending = 'estimate_slippage-full_eyelid_shape', -# calibration = 'monocular_tps_cv_cluster_median_conf75_cut3std', # monocular_tps_default' -# gaze_mapping = 'default_mapper', -# error = '', -# ) pipeline_default = dict( pupil_tag='pylids_pupils_eyelids_v2', pupil_detrend_tag=None, @@ -101,9 +137,9 @@ def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): validation_marker_tag='checkerboard_halfres_4x7squares', validation_split_tag=None, validation_cluster_tag='cluster_checkerboards', - calibration_tag='monocular_tps_cv_cluster_median_conf75_cut3std', + calibration_tag='monocular_tps_cv_cluster_median_conf40_cut3std', gaze_tag='default_mapper', - error_tag='smooth_tps_cv_clust_med_outlier4std_conf75', + error_tag='smooth_tps_cv_clust_med_outlier4std_conf40', calibration_epoch=0, ) @@ -125,12 +161,57 @@ def make_file_strings( # Extra eye=None, fov_str=None, + validation_checkerboard_size = '4x7', validation_epoch=0): + """ + Construct filename templates for gaze-pipeline outputs. + + Parameters + ---------- + pupil_tag : str + Tag for the pupil detection algorithm (used in pupil filename). + eyelid_tag : str or None + Reserved for eyelid-related tagging (not currently used). + pupil_detrend_tag : str or None + Detrending tag for pupil processing. + calibration_marker_tag : str or None + Marker tag used for calibration. + calibration_split_tag : str or None + Split tag for calibration. + calibration_cluster_tag : str or None + Clustering tag for calibration markers. + validation_marker_tag, validation_split_tag, validation_cluster_tag : str or None + Tags used for validation marker detection and processing. + calibration_tag : str + Calibration algorithm tag used in gaze filename. + gaze_tag : str + Gaze mapping algorithm tag. + error_tag : str + Error-processing tag used in error filename. + calibration_epoch : int + Epoch index used in calibration filename hashing. + eye : str or None + Placeholder or format for eye side in filenames; defaults to '%s'. + fov_str : str or None + Field-of-view string inserted into error filename; defaults to '%s'. + validation_epoch : int + Epoch index used in validation/error filename. + + Returns + ------- + out : dict + Dictionary of filename templates with keys 'pupil_file', 'gaze_file', and + 'error_file'. The templates include format placeholders for eye and fov + where appropriate. + """ # Hashes of inputs for steps with too many inputs for a_b_c type filename construction if fov_str is None: fov_str = '%s' if eye is None: eye = '%s' + if validation_checkerboard_size != '4x7': + validation_marker_tag.replace('4x7', validation_checkerboard_size) + calibration_args = [x for x in [calibration_marker_tag, calibration_split_tag, \ calibration_cluster_tag, f'epoch{calibration_epoch:02d}', \ pupil_tag, pupil_detrend_tag] if x is not None] @@ -151,13 +232,6 @@ def make_file_strings( return out -# input_hash = hash('-'.join([pipeline[x] for x in ['calibration_marker','calibration_filter', -# 'validation_marker','validation_filter', -# 'pupil','detrending', -# ]])) - - - # # Slippage correction # pupil_detrend_path = f'pupil_detrended-%s-{pipeline['detrending']}.npz' # calibration_marker_path = f'markers-calibration-{calibration_marker_string}-epochall.npz' # epoch all - revisit? @@ -166,100 +240,143 @@ def make_file_strings( # error_str = 'error_%s.npz' # gaze_str = 'gaze_%s.npz' -class Session(MappedClass): +class Session(object): """Representation of a VEDB recording session. Contains paths to all relevant files (world video, eye videos, etc.) and means to load them, as well as meta-data about the session. """ - def __init__(self, - folder=None, - subject=None, - date=None, - task=None, - location=None, - fov=None, - # Database bureaucracy - type='Session', - dbi=None, - _id=None, - _rev=None, - **kwargs): + def __init__(self, folder, clip_labels='native', clock='native'): """Class for a data collection session for vedb project """ - self.type = 'Session' + # Check for existence of folder + if not isinstance(folder, pathlib.Path): + folder = pathlib.Path(folder) + # Check if folder is locally defined + if folder.parent == pathlib.Path('.'): + if folder.exists(): + folder = folder.absolute() + else: + folder = pathlib.Path(BASE_PATH) / folder.name + if not folder.exists(): + raise ValueError(f"Folder {folder.name} not found!") + # Check on files available in folder + missing_files = _check_paths(folder) + if (len(missing_files) > 0) & raise_error: + raise ValueError(f'Missing files: {missing_files}\n') + self.folder = folder - self.subject=subject - self.fov=fov - self.dbi = dbi - self._id = _id - self._rev = _rev - self.date = date + self.clock = clock self._base_path = BASE_PATH self._path = None self._paths = None self._features = None self._world_time = None self._recording_duration = self.world_time[-1] - self.world_time[0] - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [ - 'path', - 'paths', - 'clips', - 'datetime', - 'world_time', - 'recording_duration'] - # Fields that are other database objects - self._db_fields = [] - # This might be time consuming for large queries... - self.load_clips() - - def load_clips(self): + if clip_labels is not None: + self.load_clips(clip_labels) + + def load_clips(self, clip_labels='native'): clip_file = pathlib.Path(self.path) / f'{self.folder}.csv' if clip_file.exists(): - self.clips = parse_csv(clip_file) - - def load_gaze_pipeline(self, pipeline='latest', is_verbose=0): + try: + self.clips = parse_csv(clip_file) + except: + print("Can't parse clips!") + else: + print("No clips defined!") + if clip_labels == 'native': + clip_labels = ('native', 'native') + loc_mapping, task_mapping = clip_labels + # Location mapping + if loc_mapping == 'native': + pass + else: + loc_mapping = utils.read_yaml(BASE_PATH / f'{loc_mapping}.yaml') + for j in range(len(self.clip)): + self.clips[j].location = loc_mapping[self.clips[j].location] + # Task mapping + if loc_mapping == 'native': + pass + else: + task_mapping = utils.read_yaml(BASE_PATH / f'{task_mapping}.yaml') + for j in range(len(self.clip)): + self.clips[j].task = task_mapping[self.clips[j].task] + for j in range(len(self.clips)): + self.clips[j].tag = f'{self.clips[j].location}:{self.clips[j].task}' + + def load_gaze_pipeline(self, pipeline=None, clock='native'): """load all elements of a gaze pipeline Parameters ---------- - pipeline : str, optional - tag (name) of pipeline, by default 'latest', which (medium intelligently) - finds latest greatest estimate of gaze + pipeline : dict, optional + dict of preprocessing step tags (strings), each of which identifies + how a step of preprocessing was handled. Default values are used if + `None` is_verbose : int or bool False or 0 : print nothing True or 1 : print whether each element was found 2+ : print all above and database query results """ - if self.dbi is None: - warnings.warn('self.dbi must be active database interface for this to work') - return None - # Get list of pipeline keys - pl = self.dbi.query(1, type='ParamDictionary', fn='vedb_gaze.pipelines.make_pipeline', tag=pipeline) - ple = load_pipeline_elements(self, dbi=self.dbi, is_verbose=is_verbose, **pl.params) - return ple + self.clock = clock + if pipeline is None: + pipeline = pipeline_default + # Get field of view and validation checkerboard size from SESSION_INFO file + si = get_session_info(self.folder) + if si['val_4x7']: + validation_checkerboard_size = '4x7' + elif si['val_7x9']: + validation_checkerboard_size = '7x9' + else: + raise ValueError('Could not figure out what size validation checkerboard was for this session!') + self.gaze_paths = make_file_strings(pipeline, fov_str=f'{si["fov"]}', eye=None, + validation_checkerboard_size=validation_checkerboard_size) + - def load_gaze(self, pipeline='latest', clock='native', time_idx=None): + def load_gaze(self, pipeline=None, eye='both', clock='native'): """Load estimate of gaze based on particular pipeline tag + See also Session.gaze and Session.load_gaze_pipeline() + Parameters ---------- - pipeline : str, optional - name of pipeline, by default 'latest', which (medium intelligently) - pulls latest estimate of gaze + pipeline : dict, optional + dict of preprocessing step tags (strings), each of which identifies + how a step of preprocessing was handled. Default values are used if + `None` clock : str, optional which timestamps gaze should have. Default is 'native', which means at ~120 hz - (native eye camera temporal resolution). 'world' specifies that gaze should be + (native eye camera temporal resolution). 'world_time' specifies that gaze should be matched to nearest world timestamp (or averaged over a window according to `kwargs` that are passed on to `match_timepoints()`) """ - ple = self.load_gaze_pipeline(pipeline=pipeline) - return ple['gaze'] + # Get field of view and validation checkerboard size from SESSION_INFO file + si = get_session_info(self.folder) + if si['val_4x7']: + validation_checkerboard_size = '4x7' + elif si['val_7x9']: + validation_checkerboard_size = '7x9' + else: + raise ValueError('Could not figure out what size validation checkerboard was for this session!') + gaze_paths = make_file_strings(pipeline, fov_str=f'{si["fov"]}', eye=None, + validation_checkerboard_size=validation_checkerboard_size) + + gaze = dict(left=np.load(PROC_PATH / self.folder / gaze_paths['gaze_file']%'left'), + right=np.load(PROC_PATH / self.folder / gaze_paths['gaze_file']%'right')) + if eye == 'left': + return gaze['left'] + elif eye == 'right': + return gaze['right'] + elif eye == 'both': + return gaze + elif eye == 'best': + if self.error['left'][WORKING] < self.error['right'][WORKING]: + return gaze['left'] + else: + return gaze['right'] + return def load(self, data_type, time_idx=None, frame_idx=None, **kwargs): """ @@ -394,7 +511,7 @@ def paths(self): return self._paths @classmethod - def from_folder(cls, folder, dbinterface=None, subject=None, raise_error=True, db_save=False, load_label_csv=True): + def from_folder(cls, folder, raise_error=True, load_label_csv=True): """Creates a new instance of this class from the given `docdict`. Parameters @@ -425,14 +542,7 @@ def from_folder(cls, folder, dbinterface=None, subject=None, raise_error=True, d missing_files = _check_paths(folder) if (len(missing_files) > 0) & raise_error: raise ValueError(f'Missing files: {missing_files}\n') - # Check for presence of folder in database if we are aiming to save session in database - if db_save: - check = dbinterface.query(type='Session', folder=folder.name) - if len(check) > 0: - print('SESSION FOUND IN DATABASE.') - return check[0] - elif len(check) > 1: - raise Exception('More than one database session found with this date!') + # VEDB specific things: folder name as date, csv file for task, location labels # look for session csv for vedb @@ -446,8 +556,7 @@ def from_folder(cls, folder, dbinterface=None, subject=None, raise_error=True, d date = None print('Folder name not parseable as a date') - ob.__init__(dbi=dbinterface, - subject=subject, + ob.__init__( folder=folder.name, date=date ) @@ -514,7 +623,7 @@ def location(self,): if ':' in self.tag: return self.tag.split(':')[0] - def load_gaze_pipeline(self, pipeline=pipeline_default, clock='native', eye=('left','right')): + def load_gaze_pipeline(self, pipeline=pipeline_default, clock='native'): """Load specific gaze pipeline. Allows for non-default gaze to be loaded. """ @@ -530,11 +639,12 @@ def pupil(self): self.load_gaze_pipeline() if self._pupil is None: self._pupil = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ - for lr, fname in self.gaze_dict['pupil'].items()) + for lr, fname in self.gaze_paths['pupil_file'].items()) # Enumerate options here for clock if self.clock is not 'native': + this_time = getattr(self, this_time) for e in self._pupil.keys(): - self._pupil[e] = match_time_points([dict(timestamp=self.world_time), self._pupil[e]]) + self._pupil[e] = utils.match_time_points([dict(timestamp=this_time), self._pupil[e]]) return self._pupil @property @@ -543,13 +653,18 @@ def gaze(self): self.load_gaze_pipeline() if self._gaze is None: self._gaze = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ - for lr, fname in self.gaze_dict['gaze'].items()) + for lr, fname in self.gaze_paths['gaze_file'].items()) # Enumerate options here for clock if self.clock is not 'native': + this_time = getattr(self, this_time) for e in self._gaze.keys(): - self._gaze[e] = match_time_points([dict(timestamp=self.world_time), self._gaze[e]]) + self._gaze[e] = utils.match_time_points([dict(timestamp=this_time), self._gaze[e]]) return self._gaze + @property + def gaze_best(self): + pass + def binary(self, timestamps, comparison_type=('>=', '<'), pre=0, post=0): """Get binary index for this clip within `timestamps` @@ -647,6 +762,8 @@ def load(self, data_type, **kwargs): time = world_time[self.binary(world_time)] return time elif data_type == 'gaze': + ses = Session(self.session) + gaze = ses.load_gaze() out = load_gaze(self.session, **kwargs) return self(out) elif data_type == 'odometry': @@ -715,7 +832,6 @@ def make_gaze_animation(self, wspace=None, eye_left_color=(1.0, 0.5, 0.0), # orange eye_right_color=(0.0, 0.5, 1.0), # cyan - session_info=SESSION_INFO, raise_error=False, ): """Make radical gaze animation""" @@ -724,9 +840,8 @@ def make_gaze_animation(self, global eye_right_frame global eye_right_image eye_video_size = 400 # x 400, square - tmp = utils.arraydict_to_dictlist(session_info) - si_dict = dict((x['folder'], x) for x in tmp) - si = si_dict[self.session] + si = get_session_info(self.session) + try: ses = Session.from_folder(self.session, raise_error=raise_error) pl = PROC_PATH / ses.folder / (pupil_str%'left') diff --git a/vedb_store/orm/subject.py b/vedb_store/orm/subject.py deleted file mode 100644 index 984e805..0000000 --- a/vedb_store/orm/subject.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Class for subject info""" - -from .mappedclass import MappedClass -from .. import options -import file_io -import textwrap -import numpy as np -import warnings -import yaml -import os - -BASE_PATH = options.config.get('paths', 'vedb_directory') - - -# Question: track data_available in database? -# For feature extraction: there may be multiple versions and/or parameters that we would like to use to compute stuff. -# e.g. for gaze data. How to track that? -# Separate database class for processed session, w/ param dicts and preprocessing sequences? - -# dbi field - need it, yes? - -class Subject(MappedClass): - def __init__(self, subject_id=None, birth_year=None, gender=None, ethnicity=None, IPD=None, height=None, - type='Subject', dbi=None, age=None, _id=None, _rev=None): - """Class for a data collection session for vedb project - start_time : float - Start time is the common start time for all clocks. Necessary for syncronization of disparate - frame rates and start lags - - """ - - - inpt = locals() - self.type = 'Subject' - for k, v in inpt.items(): - if k == 'age': - warnings.warn("`age` field is deprecated; use `birth_year` instead!") - if not k in ['self', 'type']: - setattr(self, k, v) - - # Introspection - # Will be written to self.fpath (if defined) - self._data_fields = [] - # Constructed on the fly and not saved to docdict - self._temp_fields = [] - # Fields that are other database objects - self._db_fields = [] - - def __repr__(self): - rstr = textwrap.dedent(""" - vedb_store.Subject - {id:>12s}: {subject_id} - {demo:>12s}: birth year={birth_year}, gender={gender}, height={height} - """) - return rstr.format( - id='identifier', - subject_id=self.subject_id, - demo='demographics', - birth_year=self.birth_year, - gender=self.gender, - height=self.height, - ) \ No newline at end of file diff --git a/vedb_store/utils.py b/vedb_store/utils.py index b18e15a..8abe98f 100644 --- a/vedb_store/utils.py +++ b/vedb_store/utils.py @@ -9,20 +9,12 @@ import os -SESSION_FIELDS = [#'study_site', # add back? - #'scene', - 'location', - 'task', - ] -RECORDING_FIELDS = ['tilt_angle', - 'lens', - 'rig_version', - ] - -METADATA_DEFAULTS = dict(tilt_angle='100', - lens='new', - ) - +def read_yaml(fname): + """Thin wrapper to safely read a yaml file into a dictionary""" + out = dict() + with open(fname,"r") as fid: + out = yaml.safe_load(fid) + return out def specify_marker_epochs(folder, fps=30, write_to_folder=True): ordinals = ['first', 'second', 'third', 'fourth', 'fifth', 'too many'] @@ -473,6 +465,7 @@ def load_pipeline_elements(session, dbi=None, is_verbose=1, ): + raise Exception('Deprecated! Need to re-imagine this.') if dbi is None: dbi = session.dbi verbosity = copy.copy(dbi.is_verbose) From 8303edf1a8d3cf0190557854260dc6593ba20fe7 Mon Sep 17 00:00:00 2001 From: marklescroart Date: Thu, 29 Jan 2026 16:57:09 -0800 Subject: [PATCH 2/7] FIX: removed many bugs, approximately working. --- vedb_store/orm/session.py | 97 +++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 2e4a035..5f97723 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -43,7 +43,7 @@ import copy import os -from ..utils import get_frame_indices, get_time_split, SESSION_FIELDS, load_pipeline_elements, onoff_from_binary +from ..utils import get_frame_indices, get_time_split, onoff_from_binary BASE_PATH = pathlib.Path(options.config.get('paths', 'vedb_directory')).expanduser() PROC_PATH = pathlib.Path(options.config.get('paths', 'proc_directory')).expanduser() @@ -129,7 +129,7 @@ def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): # Pipeline defaults pipeline_default = dict( - pupil_tag='pylids_pupils_eyelids_v2', + pupil_tag='pylids_pytorch_pupils_v1', pupil_detrend_tag=None, calibration_marker_tag='circles_halfres', calibration_split_tag=None, @@ -145,7 +145,7 @@ def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): def make_file_strings( - pupil_tag='pylids_pupils_eyelids_v2', + pupil_tag='pylids_pytorch_pupils_v1', eyelid_tag=None, pupil_detrend_tag=None, calibration_marker_tag='circles_halfres', @@ -154,9 +154,9 @@ def make_file_strings( validation_marker_tag='checkerboard_halfres_4x7squares', validation_split_tag=None, validation_cluster_tag='cluster_checkerboards', - calibration_tag='monocular_tps_cv_cluster_median_conf75_cut3std', + calibration_tag='monocular_tps_cv_cluster_median_conf40_cut3std', gaze_tag='default_mapper', - error_tag='smooth_tps_cv_clust_med_outlier4std_conf75', + error_tag='smooth_tps_cv_clust_med_outlier4std_conf40', calibration_epoch=0, # Extra eye=None, @@ -210,7 +210,8 @@ def make_file_strings( if eye is None: eye = '%s' if validation_checkerboard_size != '4x7': - validation_marker_tag.replace('4x7', validation_checkerboard_size) + validation_marker_tag = validation_marker_tag.replace('4x7', validation_checkerboard_size) + print(validation_marker_tag) calibration_args = [x for x in [calibration_marker_tag, calibration_split_tag, \ calibration_cluster_tag, f'epoch{calibration_epoch:02d}', \ @@ -219,15 +220,16 @@ def make_file_strings( calibration_input_hash = hashlib.blake2b(('-'.join(calibration_args)).replace('-','0').encode(), digest_size=10).hexdigest() error_args = [x for x in [calibration_marker_tag, calibration_split_tag, \ calibration_cluster_tag, f'epoch{calibration_epoch:02d}', \ - pupil_tag, pupil_detrend_tag, \ + pupil_tag, eyelid_tag, pupil_detrend_tag, \ calibration_tag, gaze_tag, validation_marker_tag, validation_split_tag, validation_cluster_tag, \ ] if x is not None] error_input_hash = hashlib.blake2b(('-'.join(error_args)).replace('-','0').encode(), digest_size=10).hexdigest() + out = dict( pupil_file = f'pupil-{eye}-{pupil_tag}.npz', gaze_file = f'gaze-{eye}-{gaze_tag}-{calibration_tag}-{calibration_input_hash}.npz', - error_file = f'error-%s-{error_tag}_{fov_str}-{error_input_hash}-epoch{validation_epoch:02d}.npz', + error_file = f'error-{eye}-{error_tag}_{fov_str}-{error_input_hash}-epoch{validation_epoch:02d}.npz', ) return out @@ -246,7 +248,7 @@ class Session(object): Contains paths to all relevant files (world video, eye videos, etc.) and means to load them, as well as meta-data about the session. """ - def __init__(self, folder, clip_labels='native', clock='native'): + def __init__(self, folder, clip_labels='native', clock='native', raise_error=True): """Class for a data collection session for vedb project """ @@ -266,11 +268,16 @@ def __init__(self, folder, clip_labels='native', clock='native'): if (len(missing_files) > 0) & raise_error: raise ValueError(f'Missing files: {missing_files}\n') - self.folder = folder + self.folder = folder.name self.clock = clock - self._base_path = BASE_PATH + self.gaze_paths = None + self._base_path = folder.parent self._path = None self._paths = None + self._gaze = None + self._pupil = None + self._eyelids = None + self._error = None self._features = None self._world_time = None self._recording_duration = self.world_time[-1] - self.world_time[0] @@ -294,17 +301,22 @@ def load_clips(self, clip_labels='native'): pass else: loc_mapping = utils.read_yaml(BASE_PATH / f'{loc_mapping}.yaml') - for j in range(len(self.clip)): - self.clips[j].location = loc_mapping[self.clips[j].location] # Task mapping if loc_mapping == 'native': pass else: task_mapping = utils.read_yaml(BASE_PATH / f'{task_mapping}.yaml') - for j in range(len(self.clip)): - self.clips[j].task = task_mapping[self.clips[j].task] for j in range(len(self.clips)): - self.clips[j].tag = f'{self.clips[j].location}:{self.clips[j].task}' + if loc_mapping == 'native': + location = self.clips[j].location + else: + location = loc_mapping[self.clips[j].location] + if task_mapping == 'native': + task = self.clips[j].task + else: + task = task_mapping[self.clips[j].task] + self.clips[j].tag = f'{location}:{task}' + def load_gaze_pipeline(self, pipeline=None, clock='native'): """load all elements of a gaze pipeline @@ -331,7 +343,7 @@ def load_gaze_pipeline(self, pipeline=None, clock='native'): validation_checkerboard_size = '7x9' else: raise ValueError('Could not figure out what size validation checkerboard was for this session!') - self.gaze_paths = make_file_strings(pipeline, fov_str=f'{si["fov"]}', eye=None, + self.gaze_paths = make_file_strings(**pipeline, fov_str=f'fov{si["fov"]:.0f}', eye=None, validation_checkerboard_size=validation_checkerboard_size) @@ -360,11 +372,19 @@ def load_gaze(self, pipeline=None, eye='both', clock='native'): validation_checkerboard_size = '7x9' else: raise ValueError('Could not figure out what size validation checkerboard was for this session!') - gaze_paths = make_file_strings(pipeline, fov_str=f'{si["fov"]}', eye=None, + if pipeline is None: + pipeline = pipeline_default + gaze_paths = make_file_strings(**pipeline, fov_str=f'fov{si["fov"]:.0f}', eye=None, validation_checkerboard_size=validation_checkerboard_size) - gaze = dict(left=np.load(PROC_PATH / self.folder / gaze_paths['gaze_file']%'left'), - right=np.load(PROC_PATH / self.folder / gaze_paths['gaze_file']%'right')) + gaze = dict(left=dict(np.load(PROC_PATH / self.folder / (gaze_paths['gaze_file']%'left'))), + right=dict(np.load(PROC_PATH / self.folder / (gaze_paths['gaze_file']%'right')))) + # resample to `clock` + if clock != 'native': + this_time = getattr(self, clock) + for e in gaze.keys(): + gaze[e] = utils.match_time_points([dict(timestamp=this_time), gaze[e]]) + if eye == 'left': return gaze['left'] elif eye == 'right': @@ -372,12 +392,32 @@ def load_gaze(self, pipeline=None, eye='both', clock='native'): elif eye == 'both': return gaze elif eye == 'best': - if self.error['left'][WORKING] < self.error['right'][WORKING]: + if self.error['left']['gaze_err_weighted'] < self.error['right']['gaze_err_weighted']: return gaze['left'] else: return gaze['right'] return - + + @property + def gaze(self): + """Always native eye video time for both eyes, if you want other clocks use Session.load_gaze()""" + if self.gaze_paths is None: + self.load_gaze_pipeline() + if self._gaze is None: + self._gaze = dict((lr, dict(np.load(PROC_PATH / self.folder / (self.gaze_paths['gaze_file']%lr)))) \ + for lr in ['left','right']) + return self._gaze + + @property + def error(self): + """Only for epoch 1 if it exists, else nan""" + if self.gaze_paths is None: + self.load_gaze_pipeline() + if self._error is None: + self._error = dict((lr, dict(np.load(PROC_PATH / self.folder / (self.gaze_paths['error_file']%lr)))) \ + for lr in ['left','right']) + return self._error + def load(self, data_type, time_idx=None, frame_idx=None, **kwargs): """ Parameters @@ -486,7 +526,7 @@ def paths(self): to_find = [('world.mp4','worldPrivate.mp4'), ('eye1.mp4', 'eye1_blur.mp4'), ('eye0.mp4','eye0_blur.mp4'), 'odometry.pldata'] names = ['world_camera', 'eye_left', 'eye_right', 'odometry'] _paths = {} - base_path = self._resolve_sync_dir(self.path) + base_path = self.path # TODO: re-implement? self._resolve_sync_dir(self.path) for fnm, nm in zip(to_find, names): if isinstance(fnm, tuple): ff = 'no_file.nope' @@ -630,7 +670,7 @@ def load_gaze_pipeline(self, pipeline=pipeline_default, clock='native'): self.clock = clock if self.session is None: return None - self.gaze_paths = make_file_strings(pipeline) + self.gaze_paths = make_file_strings(**pipeline) #self._error = dict((lr, np.load(self.gaze_paths['gaze']%lr)) for lr in eye) @property @@ -641,7 +681,7 @@ def pupil(self): self._pupil = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ for lr, fname in self.gaze_paths['pupil_file'].items()) # Enumerate options here for clock - if self.clock is not 'native': + if self.clock != 'native': this_time = getattr(self, this_time) for e in self._pupil.keys(): self._pupil[e] = utils.match_time_points([dict(timestamp=this_time), self._pupil[e]]) @@ -655,7 +695,7 @@ def gaze(self): self._gaze = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ for lr, fname in self.gaze_paths['gaze_file'].items()) # Enumerate options here for clock - if self.clock is not 'native': + if self.clock != 'native': this_time = getattr(self, this_time) for e in self._gaze.keys(): self._gaze[e] = utils.match_time_points([dict(timestamp=this_time), self._gaze[e]]) @@ -763,9 +803,8 @@ def load(self, data_type, **kwargs): return time elif data_type == 'gaze': ses = Session(self.session) - gaze = ses.load_gaze() - out = load_gaze(self.session, **kwargs) - return self(out) + gaze = ses.load_gaze(**kwargs) + return self(gaze) elif data_type == 'odometry': odo = file_io.load_msgpack((BASE_PATH / self.session / 'odometry.pldata')) odo = utils.dictlist_to_arraydict(odo) From 0ba8d237319b166a90fb987fca3f0a699553af93 Mon Sep 17 00:00:00 2001 From: marklescroart Date: Thu, 5 Feb 2026 09:10:50 -0800 Subject: [PATCH 3/7] FIX: minor change to make time_to_index() function work correctly --- vedb_store/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vedb_store/utils.py b/vedb_store/utils.py index 8abe98f..b51dc5a 100644 --- a/vedb_store/utils.py +++ b/vedb_store/utils.py @@ -429,9 +429,9 @@ def time_to_index(onsets_offsets, timeline, index_type='integer'): onsets_offsets = np.asarray(onsets_offsets) out = np.zeros(onsets_offsets.shape, dtype=int) for ct, (on, off) in enumerate(onsets_offsets): - i = np.flatnonzero(timeline > on)[0] + i = np.flatnonzero(timeline >= on)[0] j = np.flatnonzero(timeline < off)[-1] - out[ct] = [int(i), int(j)] + out[ct] = [int(i), int(j)+1] if index_type=='boolean': out = onoff_to_binary(out, len(timeline)) return out From 2f74087a6bd8db27098f4a7730d5349d2d40754f Mon Sep 17 00:00:00 2001 From: marklescroart Date: Mon, 30 Mar 2026 08:56:54 -0700 Subject: [PATCH 4/7] ENH: added __and__method to ClipList --- vedb_store/orm/session.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 5f97723..9273f82 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -1252,6 +1252,23 @@ def __add__(self, cliplist): b = cliplist.binary(self.native_timestamps) return ClipList.from_binary(a | b, self.native_timestamps, session=self.session) #, tags=self.tags) + def __and__(self, cliplist): + new_clips = [] + #chk = cliplist.binary(self.native_timestamps) + for clip in self: + onset_btw = any([(other_clip.onset >= clip.onset) &\ + (other_clip.onset <= clip.offset) for other_clip in cliplist]) + offset_btw = any([(other_clip.offset >= clip.onset) &\ + (other_clip.offset <= clip.offset) for other_clip in cliplist]) + if onset_btw | offset_btw: + new_clips.append(clip) + if len(new_clips) == 0: + return [] + onoffs = [(x.onset, x.offset) for x in new_clips] + tags = [x.tag for x in new_clips] + return ClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) + + def __len__(self): return(len(self.clip_list)) From deaf567338a2adfbffcfe860276580201fc4e64e Mon Sep 17 00:00:00 2001 From: marklescroart Date: Sun, 26 Apr 2026 21:56:07 -0700 Subject: [PATCH 5/7] FIX: fixing assumed pupil file name error, adding option for eye privacy setting --- vedb_store/orm/session.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 9273f82..65ed7fc 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -48,7 +48,7 @@ BASE_PATH = pathlib.Path(options.config.get('paths', 'vedb_directory')).expanduser() PROC_PATH = pathlib.Path(options.config.get('paths', 'proc_directory')).expanduser() SESSION_INFO = dict(np.load(BASE_PATH / 'session_info.npz')) - +EYE_PRIVACY_SETTING = '' # '_blur' import file_io import matplotlib.pyplot as plt @@ -227,7 +227,7 @@ def make_file_strings( error_input_hash = hashlib.blake2b(('-'.join(error_args)).replace('-','0').encode(), digest_size=10).hexdigest() out = dict( - pupil_file = f'pupil-{eye}-{pupil_tag}.npz', + pupil_file = f'pupil_detection-{eye}-{pupil_tag}.npz', gaze_file = f'gaze-{eye}-{gaze_tag}-{calibration_tag}-{calibration_input_hash}.npz', error_file = f'error-{eye}-{error_tag}_{fov_str}-{error_input_hash}-epoch{validation_epoch:02d}.npz', ) @@ -598,7 +598,6 @@ def from_folder(cls, folder, raise_error=True, load_label_csv=True): ob.__init__( folder=folder.name, - date=date ) return ob @@ -814,7 +813,7 @@ def load(self, data_type, **kwargs): return self(odo) elif data_type in ('eye_left', 'eye_right'): lr = '1' if 'left' in data_type else '0' - fname = BASE_PATH / self.session / f'eye{lr}_blur.mp4' + fname = BASE_PATH / self.session / f'eye{lr}{EYE_PRIVACY_SETTING}.mp4' ftime = BASE_PATH / self.session / f'eye{lr}_timestamps_0start.npy' data_time = np.load(ftime) data = file_io.load_video(fname, frames=self.indices(data_time), **kwargs) @@ -1097,8 +1096,11 @@ def animate(i): anim = animation.FuncAnimation(fig, animate, init_func=init, frames=n_frames, interval=1/fps * 1000, blit=True) except: - eye_left_vid.VideoObj.release() - eye_right_vid.VideoObj.release() + try: + eye_left_vid.VideoObj.release() + eye_right_vid.VideoObj.release() + except: + pass raise return anim From 557798fc30c154dc6722498443254f50f4d3bff0 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 1 Jun 2026 16:17:17 +0000 Subject: [PATCH 6/7] Updates to SessionClip, ClipList, SEssionClipList --- vedb_store/orm/session.py | 278 ++++++++++++++++++++++++++++++++++---- 1 file changed, 252 insertions(+), 26 deletions(-) diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 65ed7fc..16d3b09 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -12,9 +12,13 @@ - SessionClip Represents a labeled time interval within a session and provides methods to slice, sample, and visualize data for that interval. +- SessionClipList + Container for multiple SessionClip instances WITHIN A SINGLE + SESSION with logical operations and conversion utilities. - ClipList - Container for multiple SessionClip instances with logical - operations and conversion utilities. + Container for multiple SessionClip instances ACROSS sessions. + These are each meant to constitute a sample of VEDB to be + analyzed. Notes ----- @@ -47,7 +51,9 @@ BASE_PATH = pathlib.Path(options.config.get('paths', 'vedb_directory')).expanduser() PROC_PATH = pathlib.Path(options.config.get('paths', 'proc_directory')).expanduser() +SAMPLE_PATH = pathlib.Path(options.config.get('paths', 'sample_directory')).expanduser() SESSION_INFO = dict(np.load(BASE_PATH / 'session_info.npz')) +SESSION_ERROR = dict(np.load(BASE_PATH / 'session_error.npz')) EYE_PRIVACY_SETTING = '' # '_blur' import file_io @@ -100,7 +106,26 @@ def show_ellipse(ellipse, img=None, ax=None, center_color='r', **kwargs): pt_h = ax.scatter(ellipse["center"][0], ellipse["center"][1], color=center_color) return patch_h, pt_h - +def unique(seq, idfun=None): + """Returns only unique values in a list (with order preserved). + (idfun can be defined to select particular values??) + + Stolen from the internets 11.29.11 + """ + # order preserving + if idfun is None: + def idfun(x): return x + seen = {} + result = [] + for item in seq: + marker = idfun(item) + if marker in seen: + seen[marker]+=1 + continue + else: + seen[marker] = 1 + result.append(item) + return result, seen REQUIRED_FILES = [ 'accel.pldata', @@ -211,7 +236,7 @@ def make_file_strings( eye = '%s' if validation_checkerboard_size != '4x7': validation_marker_tag = validation_marker_tag.replace('4x7', validation_checkerboard_size) - print(validation_marker_tag) + #print(validation_marker_tag) calibration_args = [x for x in [calibration_marker_tag, calibration_split_tag, \ calibration_cluster_tag, f'epoch{calibration_epoch:02d}', \ @@ -318,7 +343,7 @@ def load_clips(self, clip_labels='native'): self.clips[j].tag = f'{location}:{task}' - def load_gaze_pipeline(self, pipeline=None, clock='native'): + def load_gaze_pipeline(self, pipeline=None, clock='native', validation_epoch=0, eye=None): """load all elements of a gaze pipeline Parameters @@ -343,7 +368,7 @@ def load_gaze_pipeline(self, pipeline=None, clock='native'): validation_checkerboard_size = '7x9' else: raise ValueError('Could not figure out what size validation checkerboard was for this session!') - self.gaze_paths = make_file_strings(**pipeline, fov_str=f'fov{si["fov"]:.0f}', eye=None, + self.gaze_paths = make_file_strings(**pipeline, fov_str=f'fov{si["fov"]:.0f}', eye=eye, validation_epoch=validation_epoch, validation_checkerboard_size=validation_checkerboard_size) @@ -472,7 +497,7 @@ def load(self, data_type, time_idx=None, frame_idx=None, **kwargs): def get_video_handle(self, stream): """Return an opencv """ - return file_io.VideoCapture(self.paths[stream][1]) + return file_io.VideoCapture(str(self.paths[stream][1])) def get_video_time(self, stream): return np.load(self.paths[stream][0]) @@ -662,16 +687,53 @@ def location(self,): if ':' in self.tag: return self.tag.split(':')[0] - def load_gaze_pipeline(self, pipeline=pipeline_default, clock='native'): + def load_gaze_pipeline(self, pipeline=None, clock='native', eye=None, validation_epoch=0): """Load specific gaze pipeline. Allows for non-default gaze to be loaded. """ self.clock = clock if self.session is None: return None - self.gaze_paths = make_file_strings(**pipeline) + if pipeline is None: + pipeline=pipeline_default + # Get field of view and validation checkerboard size from SESSION_INFO file + si = get_session_info(self.session) + if si['val_4x7']: + validation_checkerboard_size = '4x7' + elif si['val_7x9']: + validation_checkerboard_size = '7x9' + else: + raise ValueError('Could not figure out what size validation checkerboard was for this session!') + + self.gaze_paths = make_file_strings(**pipeline, fov_str=f'fov{si["fov"]:.0f}', eye=eye, validation_epoch=validation_epoch, + validation_checkerboard_size=validation_checkerboard_size) + #self._error = dict((lr, np.load(self.gaze_paths['gaze']%lr)) for lr in eye) + def quickshow(self, n=5, buffer=0.05, with_gaze_box=True, axs=None): + if self.session is None: + raise ValueError('Must have session defined to work.') + ses = Session(self.session) + st, fin = self.indices(ses.world_time) + dur = fin - st + # Allow a buffer at start and end + st = int(np.round(st + dur * buffer)) + fin = int(np.round(fin - dur * buffer)) + ii = np.round(np.linspace(st, fin, n)).astype(int) + samples = [] + for i in ii: + # TO DO: incorporate option for gaze-centered + img = ses.load('world_camera', size=0.25, frame_idx=(i, ii+1))[1][0] + samples.append(img) + if axs is None: + ar = 4/3 + scale = 2 + fig, axs = plt.subplots(1,n, figsize=(n*scale*ar, scale)) + for img, ax in zip(samples, axs.flatten()): + ax.imshow(img) + ax.axis('off') + # Show gaze rect + @property def pupil(self): if self.gaze_paths is None: @@ -683,7 +745,7 @@ def pupil(self): if self.clock != 'native': this_time = getattr(self, this_time) for e in self._pupil.keys(): - self._pupil[e] = utils.match_time_points([dict(timestamp=this_time), self._pupil[e]]) + self._pupil[e] = utils.match_time_points(dict(timestamp=this_time), self._pupil[e]) return self._pupil @property @@ -691,13 +753,17 @@ def gaze(self): if self.gaze_paths is None: self.load_gaze_pipeline() if self._gaze is None: - self._gaze = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ - for lr, fname in self.gaze_paths['gaze_file'].items()) + self._gaze = dict((lr, dict(np.load(PROC_PATH / self.session / (self.gaze_paths['gaze_file']%lr)))) \ + for lr in ['left','right']) # Enumerate options here for clock if self.clock != 'native': - this_time = getattr(self, this_time) + if self.clock == 'world_time': + this_time = self.load('world_time') + else: + raise NotImplementedError("can only handle 'native' and 'world_time' for clocks so far") + #this_time = getattr(self, this_time) for e in self._gaze.keys(): - self._gaze[e] = utils.match_time_points([dict(timestamp=this_time), self._gaze[e]]) + self._gaze[e] = utils.match_time_points(dict(timestamp=this_time), self._gaze[e]) return self._gaze @property @@ -840,6 +906,7 @@ def sample(self, start_times += self.onset output = np.array([start_times, start_times+sample_duration]).T return [SessionClip(*times, tag=self.tag, session=self.session) for times in output] + def shade_bg(self, ax=None, fcol=(.9, .9, .9), yl=None, vert=False, zorder=-1): """Shade in xtick grid (every other tick mark is gray/white)""" if ax is None: @@ -900,7 +967,7 @@ def make_gaze_animation(self, eye_right_time = np.load(ertf) eye_right_vid = ses.get_video_handle('eye_right') - _, vh, vw, _ = file_io.list_array_shapes(ses.paths['world_camera'][1]) + _, vh, vw, _ = file_io.list_array_shapes(str(ses.paths['world_camera'][1])) n_frames = len(self(dict(timestamp=ses.world_time))['timestamp']) #frame = world[0] rect_width = rect_size[0] / vw @@ -982,10 +1049,12 @@ def make_gaze_animation(self, ax=ax_eye_right) ax_eye_left.axis([1, 0, 1, 0]) # [0,1, 0, 1] + #ax_eye_left.axis([0, 1, 0, 1]) # [0,1, 0, 1] ax_eye_left.set_xticks([]) ax_eye_left.set_yticks([]) - ax_eye_right.axis([0, 1, 0, 1]) #[1, 0, 1, 0] + ax_eye_right.axis([1, 0, 1, 0]) # [0,1, 0, 1] + #ax_eye_right.axis([0, 1, 0, 1]) #[1, 0, 1, 0] ax_eye_right.set_xticks([]) ax_eye_right.set_yticks([]) @@ -1112,7 +1181,7 @@ def __repr__(self,): ) -class ClipList(object): +class SessionClipList(object): def __init__(self, onsets_offsets, native_timestamps, session=None, tags=None): """List of clips @@ -1141,7 +1210,7 @@ def binary(self, timestamps=None, comparison_type=('>=', '<'), pre=0, post=0, an """Return binary version of this list over full duration of `native_timestamps` - i.e., convert this ClipList to a vector of True values for native + i.e., convert this SessionClipList to a vector of True values for native timestamps *during* clips and False values for native timestamps *outside* of clips. """ @@ -1176,7 +1245,7 @@ def filter_duration(self, min_duration=0, max_duration=np.inf): """Durations in seconds""" onoff_times = self.times(include_duration=True) onoffs_filtered = [x for x in onoff_times if (x[2] > min_duration) and (x[2] < max_duration)] - return ClipList(onoffs_filtered, self.native_timestamps, session=self.session, tags=self.tags) + return SessionClipList(onoffs_filtered, self.native_timestamps, session=self.session, tags=self.tags) def dilate(self, pre=0, post=0, merge=False): """dilate time for each clip. Times in seconds. @@ -1184,7 +1253,7 @@ def dilate(self, pre=0, post=0, merge=False): """ if merge: ii = self.binary(pre=pre, post=post) - out = ClipList.from_binary(ii, self.native_timestamps, session=self.session) + out = SessionClipList.from_binary(ii, self.native_timestamps, session=self.session) else: # Don't overwrite self mn = self.native_timestamps.min() @@ -1192,11 +1261,11 @@ def dilate(self, pre=0, post=0, merge=False): onoff_clips = [(x.dilate(pre=pre, post=post, tlimits=(mn, mx), )) \ for x in self] onoff_times = [(x.onset, x.offset) for x in onoff_clips] - out = ClipList(onoff_times, self.native_timestamps, session=self.session, tags=self.tags) + out = SessionClipList(onoff_times, self.native_timestamps, session=self.session, tags=self.tags) return out def invert(self, **kwargs): - out = ClipList.from_binary(~self.binary(**kwargs), self.native_timestamps, session=self.session) + out = SessionClipList.from_binary(~self.binary(**kwargs), self.native_timestamps, session=self.session) # Kill any 1-frame clips out.clip_list = [clip for clip in out if clip.duration > 0] return out @@ -1226,7 +1295,7 @@ def from_clips(cls, list_of_clips, native_timestamps): # Require that all clips are from same session. For now, clip lists must tbe same session. session = list_of_clips[0].session assert all([x.session == session for x in list_of_clips]),\ - 'SessionClip objects in ClipList must be from same session!' + 'SessionClip objects in SessionClipList must be from same session!' onoffs = [(x.onset, x.offset) for x in list_of_clips] tags = [x.tag for x in list_of_clips] ob.__init__(onoffs, native_timestamps, session=session, tags=tags) @@ -1246,13 +1315,13 @@ def __sub__(self, cliplist): return [] onoffs = [(x.onset, x.offset) for x in new_clips] tags = [x.tag for x in new_clips] - return ClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) + return SessionClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) #return ClipList.from_binary(bb, self.native_timestamps, self.session) def __add__(self, cliplist): a = self.binary(self.native_timestamps) b = cliplist.binary(self.native_timestamps) - return ClipList.from_binary(a | b, self.native_timestamps, session=self.session) #, tags=self.tags) + return SessionClipList.from_binary(a | b, self.native_timestamps, session=self.session) #, tags=self.tags) def __and__(self, cliplist): new_clips = [] @@ -1268,7 +1337,7 @@ def __and__(self, cliplist): return [] onoffs = [(x.onset, x.offset) for x in new_clips] tags = [x.tag for x in new_clips] - return ClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) + return SessionClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) def __len__(self): @@ -1287,6 +1356,161 @@ def __iter__(self): # return ob +class ClipList(object): + def __init__(self, clips, name='MyClipList', load_gaze_centered=False): + """List of clips from potentially disparate sessions + + all times passed to this and its methods must be normalized to session start + i.e. starting when the first data stream for a session came online. + + """ + self.clip_list = clips + self.name = name + self.load_gaze_centered = load_gaze_centered + + def dilate(self, pre=0, post=0): + """Dilate time for each clip. + + No notion of merging clips - all clips in a ClipList are treated as independent. + SessionClipList clips are known to be on the same clock wrt each other, and can + be merged. + + Parameters + ---------- + pre : scalar + time to add before clip in seconds. + post : scalar + time to add before clip in seconds. + """ + # Don't overwrite self + mn = self.native_timestamps.min() + mx = self.native_timestamps.max() + onoff_clips = [(x.dilate(pre=pre, post=post, tlimits=(mn, mx), )) \ + for x in self] + onoff_times = [(x.onset, x.offset) for x in onoff_clips] + out = ClipList(onoff_times, self.native_timestamps, session=self.session, tags=self.tags) + return out + + @property + def durations(self): + return np.asarray([x.duration for x in self]) + + @property + def sessions(self): + return sorted(list(set([x.session for x in self]))) + + @property + def session_count(self): + return unique([x.session for x in self])[1] + + @property + def tasks(self): + return sorted(list(set([x.task for x in self]))) + + @property + def task_count(self): + return unique([x.task for x in self])[1] + + @property + def tasks(self): + return [x.task for x in self] + + @property + def task_count(self): + _, task_count = unique(self.tasks) + return task_count + + @property + def locations(self): + return sorted(list(set([x.location for x in self]))) + + @property + def location_count(self): + return unique([x.location for x in self])[1] + + @property + def tags(self): + return [x.tag for x in self] + + def times(self, include_duration=False): + return np.asarray([clip.times(include_duration) for clip in self]) + + def filter_duration(self, min_duration=0, max_duration=np.inf): + """Durations in seconds""" + output_clips = [x for x in self if (x.duration > min_duration) and (x.duration < max_duration)] + return ClipList(output_clips, name=self.name, load_gaze_centered=self.load_gaze_centered) + + + def __sub__(self, cliplist, new_name=None): + new_clips = [] + if new_name is None: + new_name=self.name + #chk = cliplist.binary(self.native_timestamps) + all_sessions = sorted(list(set(self.sessions) | set(cliplist))) + for this_session in all_sessions: + clip_setA = [x for x in self if x.session==this_session] + clip_setB = [x for x in cliplist if x.session==this_session] + for clip in clip_setA: + onset_btw = any([(other_clip.onset >= clip.onset) &\ + (other_clip.onset <= clip.offset) for other_clip in cliplistB]) + offset_btw = any([(other_clip.offset >= clip.onset) &\ + (other_clip.offset <= clip.offset) for other_clip in cliplistB]) + if not onset_btw | offset_btw: + new_clips.append(clip) + if len(new_clips) == 0: + return [] + return ClipList(new_clips, name=new_name, load_gaze_centered=self.load_gaze_centered) + + + def save(self, fname=None, path=SAMPLE_PATH): + if fname is None: + fname = f'{self.name}.npz' + fpath = path / fname + to_save = [dict((k, getattr(x, k)) for k in ['onset', 'offset', 'session','tag']) for x in self] + np.savez(fname, **utils.dictlist_to_arraydict(to_save)) + + + @classmethod + def load(cls, fpath): + if not isinstance(fpath, pathlib.Path): + fpath = pathlib.Path(fpath) + ob = cls.__new__(cls) + cc = utils.arraydict_to_dictlist(dict(np.load(fpath, allow_pickle=True))) + ob.__init__([SessionClip(**c) for c in cc], name=fpath.name[:-3]) + return ob + + def __add__(self, cliplist): + pass + #a = self.binary(self.native_timestamps) + #b = cliplist.binary(self.native_timestamps) + #return ClipList.from_binary(a | b, self.native_timestamps, session=self.session) #, tags=self.tags) + + def __and__(self, cliplist): + new_clips = [] + for clip in self: + onset_btw = any([(other_clip.onset >= clip.onset) &\ + (other_clip.onset <= clip.offset) for other_clip in cliplist]) + offset_btw = any([(other_clip.offset >= clip.onset) &\ + (other_clip.offset <= clip.offset) for other_clip in cliplist]) + if onset_btw | offset_btw: + new_clips.append(clip) + if len(new_clips) == 0: + return [] + onoffs = [(x.onset, x.offset) for x in new_clips] + tags = [x.tag for x in new_clips] + return ClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) + + + #def __len__(self): + # return(len(self.clip_list)) + + def __getitem__(self, i): + return self.clip_list[i] + + def __iter__(self): + return iter(self.clip_list) + + def _clean_str(x): return x.lower().strip().replace('_',' ') @@ -1396,3 +1620,5 @@ def gaze_rect(gaze_position, hdim, vdim, ax=None, linewidth=1, edgecolor='r', ** # Add the patch to the Axes rh = ax.add_patch(rect) return rh + + From e43afc22bb63a87b0f3f154f2ba7a6b81cec2543 Mon Sep 17 00:00:00 2001 From: marklescroart Date: Fri, 12 Jun 2026 13:35:10 -0700 Subject: [PATCH 7/7] Further updates to SessionClip --- vedb_store/orm/session.py | 44 +++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/vedb_store/orm/session.py b/vedb_store/orm/session.py index 16d3b09..0a72aca 100644 --- a/vedb_store/orm/session.py +++ b/vedb_store/orm/session.py @@ -54,6 +54,8 @@ SAMPLE_PATH = pathlib.Path(options.config.get('paths', 'sample_directory')).expanduser() SESSION_INFO = dict(np.load(BASE_PATH / 'session_info.npz')) SESSION_ERROR = dict(np.load(BASE_PATH / 'session_error.npz')) +SESSION_ERROR_LIST = utils.arraydict_to_dictlist(SESSION_ERROR) +SESSION_ERROR_DICT = dict((x['folder'], x) for x in SESSION_ERROR_LIST) EYE_PRIVACY_SETTING = '' # '_blur' import file_io @@ -713,6 +715,8 @@ def load_gaze_pipeline(self, pipeline=None, clock='native', eye=None, validation def quickshow(self, n=5, buffer=0.05, with_gaze_box=True, axs=None): if self.session is None: raise ValueError('Must have session defined to work.') + ar = 4/3 # Questionable to hard code this + rect_size = (600, 600) ses = Session(self.session) st, fin = self.indices(ses.world_time) dur = fin - st @@ -723,23 +727,34 @@ def quickshow(self, n=5, buffer=0.05, with_gaze_box=True, axs=None): samples = [] for i in ii: # TO DO: incorporate option for gaze-centered - img = ses.load('world_camera', size=0.25, frame_idx=(i, ii+1))[1][0] + img = ses.load('world_camera', size=0.25, frame_idx=(i, i+1))[1][0] samples.append(img) if axs is None: - ar = 4/3 scale = 2 - fig, axs = plt.subplots(1,n, figsize=(n*scale*ar, scale)) - for img, ax in zip(samples, axs.flatten()): - ax.imshow(img) + fig, axs = plt.subplots(1, n, figsize=(n * scale * ar, scale)) + do_tight_layout = True + else: + do_tight_layout = False + if with_gaze_box: + gaze = utils.match_time_points(dict(timestamp=ses.world_time), self.gaze_best) + _, vh, vw, _ = file_io.list_array_shapes(str(ses.paths['world_camera'][1])) + rect_width = rect_size[0] / vw + rect_height = rect_size[1] / vh + for j, img, ax in zip(ii, samples, axs.flatten()): + ax.imshow(img, extent=(0, 1, 1, 0), aspect='auto') ax.axis('off') # Show gaze rect + if with_gaze_box: + gaze_rect(gaze['norm_pos'][j], rect_width, rect_height, ax=ax, linewidth=2, edgecolor=(1, 0.95, 0)) + if do_tight_layout: + plt.tight_layout() @property def pupil(self): if self.gaze_paths is None: self.load_gaze_pipeline() if self._pupil is None: - self._pupil = dict((lr, np.load(PROC_PATH / self.session / fname%lr)) \ + self._pupil = dict((lr, self(np.load(PROC_PATH / self.session / fname%lr))) \ for lr, fname in self.gaze_paths['pupil_file'].items()) # Enumerate options here for clock if self.clock != 'native': @@ -753,7 +768,7 @@ def gaze(self): if self.gaze_paths is None: self.load_gaze_pipeline() if self._gaze is None: - self._gaze = dict((lr, dict(np.load(PROC_PATH / self.session / (self.gaze_paths['gaze_file']%lr)))) \ + self._gaze = dict((lr, self(dict(np.load(PROC_PATH / self.session / (self.gaze_paths['gaze_file']%lr))))) \ for lr in ['left','right']) # Enumerate options here for clock if self.clock != 'native': @@ -768,7 +783,10 @@ def gaze(self): @property def gaze_best(self): - pass + if SESSION_ERROR_DICT[self.session]['error_left_epoch0'] < SESSION_ERROR_DICT[self.session]['error_right_epoch0']: + return self.gaze['left'] + else: + return self.gaze['right'] def binary(self, timestamps, comparison_type=('>=', '<'), pre=0, post=0): """Get binary index for this clip within `timestamps` @@ -1446,15 +1464,15 @@ def __sub__(self, cliplist, new_name=None): if new_name is None: new_name=self.name #chk = cliplist.binary(self.native_timestamps) - all_sessions = sorted(list(set(self.sessions) | set(cliplist))) + all_sessions = sorted(list(set(self.sessions) | set(cliplist.sessions))) for this_session in all_sessions: clip_setA = [x for x in self if x.session==this_session] clip_setB = [x for x in cliplist if x.session==this_session] for clip in clip_setA: onset_btw = any([(other_clip.onset >= clip.onset) &\ - (other_clip.onset <= clip.offset) for other_clip in cliplistB]) + (other_clip.onset <= clip.offset) for other_clip in clip_setB]) offset_btw = any([(other_clip.offset >= clip.onset) &\ - (other_clip.offset <= clip.offset) for other_clip in cliplistB]) + (other_clip.offset <= clip.offset) for other_clip in clip_setB]) if not onset_btw | offset_btw: new_clips.append(clip) if len(new_clips) == 0: @@ -1501,8 +1519,8 @@ def __and__(self, cliplist): return ClipList(onoffs, self.native_timestamps, session=self.session, tags=tags) - #def __len__(self): - # return(len(self.clip_list)) + def __len__(self): + return(len(self.clip_list)) def __getitem__(self, i): return self.clip_list[i]