Skip to content

Commit

Permalink
Merge branch 'main' into remove_reff_resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
h-mayorquin committed Nov 15, 2024
2 parents 0cd207e + 64fb9e0 commit ad39789
Show file tree
Hide file tree
Showing 13 changed files with 226 additions and 167 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
* Completely removed compression settings from most places[PR #1126](https://github.com/catalystneuro/neuroconv/pull/1126)

## Bug Fixes
* datetime objects now can be validated as conversion options [#1139](https://github.com/catalystneuro/neuroconv/pull/1126)

## Features
* Imaging interfaces have a new conversion option `always_write_timestamps` that can be used to force writing timestamps even if neuroconv's heuristics indicates regular sampling rate [PR #1125](https://github.com/catalystneuro/neuroconv/pull/1125)
* Added .csv support to DeepLabCutInterface [PR #1140](https://github.com/catalystneuro/neuroconv/pull/1140)

## Improvements
* Use mixing tests for ecephy's mocks [PR #1136](https://github.com/catalystneuro/neuroconv/pull/1136)

# v0.6.5 (November 1, 2024)

Expand Down
5 changes: 3 additions & 2 deletions docs/conversion_examples_gallery/behavior/deeplabcut.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Install NeuroConv with the additional dependencies necessary for reading DeepLab
pip install "neuroconv[deeplabcut]"
Convert DeepLabCut pose estimation data to NWB using :py:class:`~neuroconv.datainterfaces.behavior.deeplabcut.deeplabcutdatainterface.DeepLabCutInterface`.
This interface supports both .h5 and .csv output files from DeepLabCut.

.. code-block:: python
Expand All @@ -16,8 +17,8 @@ Convert DeepLabCut pose estimation data to NWB using :py:class:`~neuroconv.datai
>>> from pathlib import Path
>>> from neuroconv.datainterfaces import DeepLabCutInterface
>>> file_path = BEHAVIOR_DATA_PATH / "DLC" / "m3v1mp4DLC_resnet50_openfieldAug20shuffle1_30000.h5"
>>> config_file_path = BEHAVIOR_DATA_PATH / "DLC" / "config.yaml"
>>> file_path = BEHAVIOR_DATA_PATH / "DLC" / "open_field_without_video" / "m3v1mp4DLC_resnet50_openfieldAug20shuffle1_30000.h5"
>>> config_file_path = BEHAVIOR_DATA_PATH / "DLC" / "open_field_without_video" / "config.yaml"
>>> interface = DeepLabCutInterface(file_path=file_path, config_file_path=config_file_path, subject_name="ind1", verbose=False)
Expand Down
4 changes: 3 additions & 1 deletion src/neuroconv/basedatainterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def create_nwbfile(self, metadata: Optional[dict] = None, **conversion_options)
return nwbfile

@abstractmethod
def add_to_nwbfile(self, nwbfile: NWBFile, **conversion_options) -> None:
def add_to_nwbfile(self, nwbfile: NWBFile, metadata: Optional[dict], **conversion_options) -> None:
"""
Define a protocol for mapping the data from this interface to NWB neurodata objects.
Expand All @@ -136,6 +136,8 @@ def add_to_nwbfile(self, nwbfile: NWBFile, **conversion_options) -> None:
----------
nwbfile : pynwb.NWBFile
The in-memory object to add the data to.
metadata : dict
Metadata dictionary with information used to create the NWBFile.
**conversion_options
Additional keyword arguments to pass to the `.add_to_nwbfile` method.
"""
Expand Down
41 changes: 13 additions & 28 deletions src/neuroconv/datainterfaces/behavior/deeplabcut/_dlc_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,21 +251,6 @@ def _get_video_info_from_config_file(config_file_path: Path, vidname: str):
return video_file_path, image_shape


def _get_pes_args(
*,
h5file: Path,
individual_name: str,
):
h5file = Path(h5file)

_, scorer = h5file.stem.split("DLC")
scorer = "DLC" + scorer

df = _ensure_individuals_in_header(pd.read_hdf(h5file), individual_name)

return scorer, df


def _write_pes_to_nwbfile(
nwbfile,
animal,
Expand Down Expand Up @@ -339,23 +324,23 @@ def _write_pes_to_nwbfile(
return nwbfile


def add_subject_to_nwbfile(
def _add_subject_to_nwbfile(
nwbfile: NWBFile,
h5file: FilePath,
file_path: FilePath,
individual_name: str,
config_file: Optional[FilePath] = None,
timestamps: Optional[Union[list, np.ndarray]] = None,
pose_estimation_container_kwargs: Optional[dict] = None,
) -> NWBFile:
"""
Given the subject name, add the DLC .h5 file to an in-memory NWBFile object.
Given the subject name, add the DLC output file (.h5 or .csv) to an in-memory NWBFile object.
Parameters
----------
nwbfile : pynwb.NWBFile
The in-memory nwbfile object to which the subject specific pose estimation series will be added.
h5file : str or path
Path to the DeepLabCut .h5 output file.
file_path : str or path
Path to the DeepLabCut .h5 or .csv output file.
individual_name : str
Name of the subject (whose pose is predicted) for single-animal DLC project.
For multi-animal projects, the names from the DLC project will be used directly.
Expand All @@ -371,18 +356,18 @@ def add_subject_to_nwbfile(
nwbfile : pynwb.NWBFile
nwbfile with pes written in the behavior module
"""
h5file = Path(h5file)

if "DLC" not in h5file.name or not h5file.suffix == ".h5":
raise IOError("The file passed in is not a DeepLabCut h5 data file.")
file_path = Path(file_path)

video_name, scorer = h5file.stem.split("DLC")
video_name, scorer = file_path.stem.split("DLC")
scorer = "DLC" + scorer

# TODO probably could be read directly with h5py
# This requires pytables
data_frame_from_hdf5 = pd.read_hdf(h5file)
df = _ensure_individuals_in_header(data_frame_from_hdf5, individual_name)
if ".h5" in file_path.suffixes:
df = pd.read_hdf(file_path)
elif ".csv" in file_path.suffixes:
df = pd.read_csv(file_path, header=[0, 1, 2], index_col=0)
df = _ensure_individuals_in_header(df, individual_name)

# Note the video here is a tuple of the video path and the image shape
if config_file is not None:
Expand All @@ -404,7 +389,7 @@ def add_subject_to_nwbfile(

# Fetch the corresponding metadata pickle file, we extract the edges graph from here
# TODO: This is the original implementation way to extract the file name but looks very brittle. Improve it
filename = str(h5file.parent / h5file.stem)
filename = str(file_path.parent / file_path.stem)
for i, c in enumerate(filename[::-1]):
if c.isnumeric():
break
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import FilePath, validate_call
from pynwb.file import NWBFile

# import ndx_pose
from ....basetemporalalignmentinterface import BaseTemporalAlignmentInterface


Expand All @@ -13,16 +14,16 @@ class DeepLabCutInterface(BaseTemporalAlignmentInterface):

display_name = "DeepLabCut"
keywords = ("DLC",)
associated_suffixes = (".h5",)
associated_suffixes = (".h5", ".csv")
info = "Interface for handling data from DeepLabCut."

_timestamps = None

@classmethod
def get_source_schema(cls) -> dict:
source_schema = super().get_source_schema()
source_schema["properties"]["file_path"]["description"] = "Path to the .h5 file output by dlc."
source_schema["properties"]["config_file_path"]["description"] = "Path to .yml config file"
source_schema["properties"]["file_path"]["description"] = "Path to the file output by dlc (.h5 or .csv)."
source_schema["properties"]["config_file_path"]["description"] = "Path to .yml config file."
return source_schema

@validate_call
Expand All @@ -34,24 +35,25 @@ def __init__(
verbose: bool = True,
):
"""
Interface for writing DLC's h5 files to nwb using dlc2nwb.
Interface for writing DLC's output files to nwb using dlc2nwb.
Parameters
----------
file_path : FilePath
path to the h5 file output by dlc.
Path to the file output by dlc (.h5 or .csv).
config_file_path : FilePath, optional
path to .yml config file
Path to .yml config file
subject_name : str, default: "ind1"
the name of the subject for which the :py:class:`~pynwb.file.NWBFile` is to be created.
The name of the subject for which the :py:class:`~pynwb.file.NWBFile` is to be created.
verbose: bool, default: True
controls verbosity.
Controls verbosity.
"""
from ._dlc_utils import _read_config

file_path = Path(file_path)
if "DLC" not in file_path.stem or ".h5" not in file_path.suffixes:
raise IOError("The file passed in is not a DeepLabCut h5 data file.")
suffix_is_valid = ".h5" in file_path.suffixes or ".csv" in file_path.suffixes
if not "DLC" in file_path.stem or not suffix_is_valid:
raise IOError("The file passed in is not a valid DeepLabCut output data file.")

self.config_dict = dict()
if config_file_path is not None:
Expand Down Expand Up @@ -108,12 +110,14 @@ def add_to_nwbfile(
nwb file to which the recording information is to be added
metadata: dict
metadata info for constructing the nwb file (optional).
container_name: str, default: "PoseEstimation"
Name of the container to store the pose estimation.
"""
from ._dlc_utils import add_subject_to_nwbfile
from ._dlc_utils import _add_subject_to_nwbfile

add_subject_to_nwbfile(
_add_subject_to_nwbfile(
nwbfile=nwbfile,
h5file=str(self.source_data["file_path"]),
file_path=str(self.source_data["file_path"]),
individual_name=self.subject_name,
config_file=self.source_data["config_file_path"],
timestamps=self._timestamps,
Expand Down
29 changes: 20 additions & 9 deletions src/neuroconv/nwbconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
unroot_schema,
)
from .utils.dict import DeepDict
from .utils.json_schema import _NWBMetaDataEncoder, _NWBSourceDataEncoder
from .utils.json_schema import (
_NWBConversionOptionsEncoder,
_NWBMetaDataEncoder,
_NWBSourceDataEncoder,
)


class NWBConverter:
Expand Down Expand Up @@ -63,11 +67,10 @@ def validate_source(cls, source_data: dict[str, dict], verbose: bool = True):

def _validate_source_data(self, source_data: dict[str, dict], verbose: bool = True):

# We do this to ensure that python objects are in string format for the JSON schema
encoder = _NWBSourceDataEncoder()
# The encoder produces a serialized object, so we deserialized it for comparison

serialized_source_data = encoder.encode(source_data)
decoded_source_data = json.loads(serialized_source_data)
encoded_source_data = encoder.encode(source_data)
decoded_source_data = json.loads(encoded_source_data)

validate(instance=decoded_source_data, schema=self.get_source_schema())
if verbose:
Expand Down Expand Up @@ -106,9 +109,10 @@ def get_metadata(self) -> DeepDict:
def validate_metadata(self, metadata: dict[str, dict], append_mode: bool = False):
"""Validate metadata against Converter metadata_schema."""
encoder = _NWBMetaDataEncoder()
# The encoder produces a serialized object, so we deserialized it for comparison
serialized_metadata = encoder.encode(metadata)
decoded_metadata = json.loads(serialized_metadata)

# We do this to ensure that python objects are in string format for the JSON schema
encoded_metadta = encoder.encode(metadata)
decoded_metadata = json.loads(encoded_metadta)

metadata_schema = self.get_metadata_schema()
if append_mode:
Expand Down Expand Up @@ -138,7 +142,14 @@ def get_conversion_options_schema(self) -> dict:

def validate_conversion_options(self, conversion_options: dict[str, dict]):
"""Validate conversion_options against Converter conversion_options_schema."""
validate(instance=conversion_options or {}, schema=self.get_conversion_options_schema())

conversion_options = conversion_options or dict()

# We do this to ensure that python objects are in string format for the JSON schema
encoded_conversion_options = _NWBConversionOptionsEncoder().encode(conversion_options)
decoded_conversion_options = json.loads(encoded_conversion_options)

validate(instance=decoded_conversion_options, schema=self.get_conversion_options_schema())
if self.verbose:
print("conversion_options is valid!")

Expand Down
25 changes: 0 additions & 25 deletions src/neuroconv/tools/testing/data_interface_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ def test_metadata_schema_valid(self, setup_interface):
Draft7Validator.check_schema(schema=schema)

def test_metadata(self, setup_interface):
# Validate metadata now happens on the class itself
metadata = self.interface.get_metadata()
self.check_extracted_metadata(metadata)

Expand Down Expand Up @@ -743,30 +742,6 @@ def test_interface_alignment(self):
pass


class DeepLabCutInterfaceMixin(DataInterfaceTestMixin, TemporalAlignmentMixin):
"""
A mixin for testing DeepLabCut interfaces.
"""

def check_interface_get_original_timestamps(self):
pass # TODO in separate PR

def check_interface_get_timestamps(self):
pass # TODO in separate PR

def check_interface_set_aligned_timestamps(self):
pass # TODO in separate PR

def check_shift_timestamps_by_start_time(self):
pass # TODO in separate PR

def check_interface_original_timestamps_inmutability(self):
pass # TODO in separate PR

def check_nwbfile_temporal_alignment(self):
pass # TODO in separate PR


class VideoInterfaceMixin(DataInterfaceTestMixin, TemporalAlignmentMixin):
"""
A mixin for testing Video interfaces.
Expand Down
21 changes: 21 additions & 0 deletions src/neuroconv/tools/testing/mock_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pynwb.base import DynamicTable

from .mock_ttl_signals import generate_mock_ttl_signal
from ...basedatainterface import BaseDataInterface
from ...basetemporalalignmentinterface import BaseTemporalAlignmentInterface
from ...datainterfaces import SpikeGLXNIDQInterface
from ...datainterfaces.ecephys.baserecordingextractorinterface import (
Expand All @@ -23,6 +24,26 @@
from ...utils import ArrayType, get_json_schema_from_method_signature


class MockInterface(BaseDataInterface):
"""
A mock interface for testing basic command passing without side effects.
"""

def __init__(self, verbose: bool = False, **source_data):

super().__init__(verbose=verbose, **source_data)

def get_metadata(self) -> dict:
metadata = super().get_metadata()
session_start_time = datetime.now().astimezone()
metadata["NWBFile"]["session_start_time"] = session_start_time
return metadata

def add_to_nwbfile(self, nwbfile: NWBFile, metadata: Optional[dict], **conversion_options):

return None


class MockBehaviorEventInterface(BaseTemporalAlignmentInterface):
"""
A mock behavior event interface for testing purposes.
Expand Down
17 changes: 17 additions & 0 deletions src/neuroconv/utils/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ def default(self, obj):
return super().default(obj)


class _NWBConversionOptionsEncoder(_NWBMetaDataEncoder):
"""
Custom JSON encoder for conversion options of the data interfaces and converters (i.e. kwargs).
This encoder extends the default JSONEncoder class and provides custom serialization
for certain data types commonly used in interface source data.
"""

def default(self, obj):

# Over-write behaviors for Paths
if isinstance(obj, Path):
return str(obj)

return super().default(obj)


def get_base_schema(
tag: Optional[str] = None,
root: bool = False,
Expand Down
Loading

0 comments on commit ad39789

Please sign in to comment.