Skip to content

Commit 973a5b7

Browse files
authored
Allow multifile ImageSeries LorenFrankLab#1445 (LorenFrankLab#1462)
* Allow multifile ImageSeries LorenFrankLab#1445 * Update changelog * Specify camera model in mocked device * Better docstrings
1 parent 85b1415 commit 973a5b7

File tree

3 files changed

+363
-39
lines changed

3 files changed

+363
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import all foreign key references.
5555
- Add custom/dynamic `AnalysisNwbfile` creation #1435
5656
- Allow nullable `DataAcquisitionDevice` foreign keys #1455
5757
- Improve error transparency on duplicate `Electrode` ids #1454
58+
- Allow multiple VideoFile entries during ingestion #1462
5859
- Decoding
5960
- Ensure results directory is created if it doesn't exist #1362
6061
- Position

src/spyglass/common/common_behav.py

Lines changed: 168 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,150 @@ class VideoFile(SpyglassMixin, dj.Imported):
376376

377377
_nwb_table = Nwbfile
378378

379+
def _prepare_video_entry(self, key, video_obj, cam_device_str):
380+
"""Prepare a VideoFile entry dict for a given video object.
381+
382+
Parameters
383+
----------
384+
key : dict
385+
The primary key for the VideoFile entry
386+
video_obj : pynwb.image.ImageSeries
387+
The video object from the NWB file
388+
cam_device_str : str
389+
Regular expression pattern to extract camera device number
390+
(e.g., r"camera_device (\d+)") as an integer.
391+
392+
Returns
393+
-------
394+
dict
395+
Prepared entry dict ready for insertion
396+
397+
Raises
398+
------
399+
KeyError
400+
If camera_name is not found in CameraDevice table
401+
"""
402+
nwb_cam_device = video_obj.device.name
403+
key["video_file_num"] = int(re.match(cam_device_str, nwb_cam_device)[1])
404+
405+
camera_name = video_obj.device.camera_name
406+
if not (CameraDevice & {"camera_name": camera_name}):
407+
raise KeyError(
408+
f"No camera with camera_name: {camera_name} found "
409+
"in CameraDevice table."
410+
)
411+
412+
key["camera_name"] = camera_name
413+
key["video_file_object_id"] = video_obj.object_id
414+
return key
415+
416+
def _process_video_timestamps(
417+
self, video_obj, valid_times, key, cam_device_str
418+
):
419+
"""Process video timestamps and collect VideoFile entries.
420+
421+
Handles both single-file and multi-file ImageSeries. For multi-file
422+
ImageSeries (indicated by starting_frame attribute), segments
423+
timestamps per file and validates each segment separately.
424+
425+
Parameters
426+
----------
427+
video_obj : pynwb.image.ImageSeries
428+
The video object from the NWB file
429+
valid_times : Interval
430+
Valid time intervals for the current epoch
431+
key : dict
432+
The primary key for the VideoFile entry
433+
cam_device_str : str
434+
Regular expression pattern to extract camera device number
435+
(e.g., r"camera_device (\d+)") as an integer.
436+
437+
Returns
438+
-------
439+
list
440+
List of entry dicts ready for insertion (may be empty)
441+
"""
442+
timestamps = video_obj.timestamps
443+
starting_frame = getattr(video_obj, "starting_frame", None)
444+
445+
# Check for multi-file ImageSeries
446+
if starting_frame is not None and len(starting_frame) > 1:
447+
return self._process_multifile_video(
448+
video_obj,
449+
timestamps,
450+
starting_frame,
451+
valid_times,
452+
key,
453+
cam_device_str,
454+
)
455+
456+
# Single-file ImageSeries: original logic for backward compatibility
457+
these_times = valid_times.contains(timestamps)
458+
if len(these_times) <= (0.9 * len(timestamps)):
459+
return []
460+
461+
entry = self._prepare_video_entry(key, video_obj, cam_device_str)
462+
return [entry]
463+
464+
def _process_multifile_video(
465+
self,
466+
video_obj,
467+
timestamps,
468+
starting_frame,
469+
valid_times,
470+
key,
471+
cam_device_str,
472+
):
473+
"""Process multi-file ImageSeries with starting_frame parameter.
474+
475+
Parameters
476+
----------
477+
video_obj : pynwb.image.ImageSeries
478+
The video object from the NWB file
479+
timestamps : array
480+
All timestamps for the ImageSeries
481+
starting_frame : array
482+
Frame indices indicating where each external file begins
483+
valid_times : Interval
484+
Valid time intervals for the current epoch
485+
key : dict
486+
The primary key for the VideoFile entry
487+
cam_device_str : str
488+
Regular expression pattern to extract camera device number
489+
(e.g., r"camera_device (\d+)") as an integer.
490+
491+
Returns
492+
-------
493+
list
494+
List of entry dicts ready for insertion (may be empty)
495+
"""
496+
entries = []
497+
498+
for file_idx in range(len(starting_frame)):
499+
# Determine timestamp range for this file segment
500+
start_idx = starting_frame[file_idx]
501+
end_idx = (
502+
starting_frame[file_idx + 1]
503+
if file_idx + 1 < len(starting_frame)
504+
else len(timestamps)
505+
)
506+
507+
# Extract timestamps for this specific file
508+
file_timestamps = timestamps[start_idx:end_idx]
509+
510+
# Check if 90% of this file's timestamps overlap with epoch
511+
these_times = valid_times.contains(file_timestamps)
512+
if len(these_times) <= (0.9 * len(file_timestamps)):
513+
continue
514+
515+
# This file segment matches the epoch - prepare VideoFile entry
516+
entry = self._prepare_video_entry(
517+
key.copy(), video_obj, cam_device_str
518+
)
519+
entries.append(entry)
520+
521+
return entries
522+
379523
def make(self, key, verbose=True, skip_duplicates=False):
380524
"""Make without optional transaction"""
381525
if not self.connection.in_transaction:
@@ -387,7 +531,8 @@ def make(self, key, verbose=True, skip_duplicates=False):
387531
nwb_file_name = key["nwb_file_name"]
388532
nwb_file_abspath = Nwbfile.get_abs_path(nwb_file_name)
389533
nwbf = get_nwb_file(nwb_file_abspath)
390-
# get all ImageSeries objects in the NWB file
534+
535+
# Get all ImageSeries objects in the NWB file
391536
videos = {
392537
obj.name: obj
393538
for obj in nwbf.objects.values()
@@ -399,7 +544,7 @@ def make(self, key, verbose=True, skip_duplicates=False):
399544
)
400545
return
401546

402-
# get the interval for the current TaskEpoch
547+
# Get the interval for the current TaskEpoch
403548
interval_list_name = (TaskEpoch() & key).fetch1("interval_list_name")
404549
valid_times = (
405550
IntervalList
@@ -410,47 +555,31 @@ def make(self, key, verbose=True, skip_duplicates=False):
410555
).fetch_interval()
411556

412557
cam_device_str = r"camera_device (\d+)"
413-
is_found = False
414-
for ind, video in enumerate(videos.values()):
415-
if isinstance(video, pynwb.image.ImageSeries):
416-
video = [video]
417-
for video_obj in video:
418-
# check to see if the times for this video_object are largely
419-
# overlapping with the task epoch times
420-
421-
timestamps = video_obj.timestamps
422-
these_times = valid_times.contains(timestamps)
423-
if not len(these_times) > (0.9 * len(timestamps)):
424-
continue
425-
426-
nwb_cam_device = video_obj.device.name
427-
428-
# returns whatever was captured in the first group (within the
429-
# parentheses) of the regular expression - in this case, 0
430-
431-
key["video_file_num"] = int(
432-
re.match(cam_device_str, nwb_cam_device)[1]
433-
)
434-
camera_name = video_obj.device.camera_name
435-
if CameraDevice & {"camera_name": camera_name}:
436-
key["camera_name"] = video_obj.device.camera_name
437-
else:
438-
raise KeyError(
439-
f"No camera with camera_name: {camera_name} found "
440-
+ "in CameraDevice table."
441-
)
442-
key["video_file_object_id"] = video_obj.object_id
443-
self.insert1(
444-
key,
445-
skip_duplicates=skip_duplicates,
446-
allow_direct_insert=True,
558+
video_inserts = []
559+
560+
for video in videos.values():
561+
video_list = (
562+
[video] if isinstance(video, pynwb.image.ImageSeries) else video
563+
)
564+
for video_obj in video_list:
565+
entries = self._process_video_timestamps(
566+
video_obj,
567+
valid_times,
568+
key.copy(),
569+
cam_device_str,
447570
)
448-
is_found = True
571+
video_inserts.extend(entries)
449572

450-
if not is_found and verbose:
573+
if video_inserts:
574+
self.insert(
575+
video_inserts,
576+
skip_duplicates=skip_duplicates,
577+
allow_direct_insert=True,
578+
)
579+
elif verbose:
451580
logger.info(
452581
f"No video found corresponding to file {nwb_file_name}, "
453-
+ f"epoch {interval_list_name}"
582+
f"epoch {interval_list_name}"
454583
)
455584

456585
@classmethod

0 commit comments

Comments
 (0)