Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dandi/cli/cmd_organize.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,16 @@
"--update-external-file-paths",
is_flag=True,
default=False,
help="Rewrite the 'external_file' arguments of ImageSeries in NWB files. "
"The new values will correspond to the new locations of the video files "
"after being organized. "
help="Rewrite the 'external_file' arguments of ImageSeries and the 'data' of "
"ExternalImage in NWB files. The new values will correspond to the new locations "
"of the referenced media files after being organized. "
"This option requires --files-mode to be 'copy' or 'move'",
)
@click.option(
"--media-files-mode",
type=EnumChoice(CopyMode),
default=None,
help="How to relocate video files referenced by NWB files",
help="How to relocate media files referenced by NWB files",
)
@click.option(
"--required-field",
Expand Down
4 changes: 4 additions & 0 deletions dandi/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ def urls(self) -> Iterator[str]:
VIDEO_FILE_EXTENSIONS = [".mp4", ".avi", ".wmv", ".mov", ".flv", ".mkv"]
VIDEO_FILE_MODULES = ["processing", "acquisition"]

#: Extensions of the images an `ExternalImage` may point at. NWB restricts `image_format` to
#: PNG, JPEG and GIF, so nothing else can be referenced by an NWB file in the first place.
IMAGE_FILE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif"]

ZARR_EXTENSIONS = [".ngff", ".zarr"]

#: Maximum allowed depth of a Zarr directory tree
Expand Down
2 changes: 2 additions & 0 deletions dandi/files/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
DandiFile,
DandisetMetadataFile,
GenericAsset,
ImageAsset,
LocalAsset,
LocalDirectoryAsset,
LocalFileAsset,
Expand All @@ -51,6 +52,7 @@
"DandisetMetadataFile",
"GenericAsset",
"GenericBIDSAsset",
"ImageAsset",
"LocalAsset",
"LocalDirectoryAsset",
"LocalFileAsset",
Expand Down
8 changes: 7 additions & 1 deletion dandi/files/_private.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@

from dandi.consts import (
BIDS_DATASET_DESCRIPTION,
IMAGE_FILE_EXTENSIONS,
VIDEO_FILE_EXTENSIONS,
ZARR_EXTENSIONS,
)
from dandi.exceptions import UnknownAssetError

from .bases import DandiFile, GenericAsset, LocalAsset, NWBAsset, VideoAsset
from .bases import DandiFile, GenericAsset, ImageAsset, LocalAsset, NWBAsset, VideoAsset
from .bids import (
BIDSAsset,
BIDSDatasetDescriptionAsset,
Expand All @@ -33,6 +34,7 @@ class DandiFileType(Enum):
VIDEO = 3
GENERIC = 4
BIDS_DATASET_DESCRIPTION = 5
IMAGE = 6

@staticmethod
def classify(path: Path) -> DandiFileType:
Expand All @@ -50,6 +52,8 @@ def classify(path: Path) -> DandiFileType:
return DandiFileType.NWB
elif path.suffix in VIDEO_FILE_EXTENSIONS:
return DandiFileType.VIDEO
elif path.suffix in IMAGE_FILE_EXTENSIONS:
return DandiFileType.IMAGE
else:
return DandiFileType.GENERIC

Expand All @@ -61,6 +65,7 @@ class DandiFileFactory:
DandiFileType.NWB: NWBAsset,
DandiFileType.ZARR: ZarrAsset,
DandiFileType.VIDEO: VideoAsset,
DandiFileType.IMAGE: ImageAsset,
DandiFileType.GENERIC: GenericAsset,
DandiFileType.BIDS_DATASET_DESCRIPTION: BIDSDatasetDescriptionAsset,
}
Expand All @@ -83,6 +88,7 @@ class BIDSFileFactory(DandiFileFactory):
DandiFileType.NWB: NWBBIDSAsset,
DandiFileType.ZARR: ZarrBIDSAsset,
DandiFileType.VIDEO: GenericBIDSAsset,
DandiFileType.IMAGE: GenericBIDSAsset,
DandiFileType.GENERIC: GenericBIDSAsset,
}

Expand Down
6 changes: 6 additions & 0 deletions dandi/files/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,12 @@ class VideoAsset(LocalFileAsset):
pass


class ImageAsset(LocalFileAsset):
"""Representation of a local image file referenced by an NWB ``ExternalImage``"""

pass


class GenericAsset(LocalFileAsset):
"""
Representation of a generic regular file, one that is not of any known type
Expand Down
2 changes: 1 addition & 1 deletion dandi/organize.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ def _get_metadata(path):
]
raise ValueError(
"--update-external-file-paths option not specified but found "
"external video files linked to the nwbfiles "
"external media files linked to the nwbfiles "
f"{', '.join(files_list)}"
)

Expand Down
156 changes: 131 additions & 25 deletions dandi/pynwb_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

from . import __version__, get_logger
from .consts import (
IMAGE_FILE_EXTENSIONS,
VIDEO_FILE_EXTENSIONS,
VIDEO_FILE_MODULES,
metadata_nwb_computed_fields,
Expand Down Expand Up @@ -349,7 +350,9 @@ def _get_session_duration(nwb: pynwb.NWBFile) -> float | None:
st_data = obj["spike_times"].target

if len(unit_end_idxs) > 1:
start = float(np.min(np.r_[st_data[0], st_data[unit_end_idxs[:-1]]]))
start = float(
np.min(np.r_[st_data[0], st_data[unit_end_idxs[:-1]]])
)
else:
start = float(st_data[0])

Expand Down Expand Up @@ -408,9 +411,16 @@ def _get_image_series(nwb: pynwb.NWBFile) -> list[dict]:
module_cont = getattr(nwb, module_name)
for name, ob in module_cont.items():
if isinstance(ob, pynwb.image.ImageSeries) and ob.external_file is not None:
out_dict = dict(id=ob.object_id, name=ob.name, external_files=[])
out_dict = dict(
id=ob.object_id,
name=ob.name,
external_files=[],
field="external_file",
)
for ext_file in ob.external_file:
if (path := PurePosixPath(ext_file)).suffix in VIDEO_FILE_EXTENSIONS:
if (
path := PurePosixPath(ext_file)
).suffix in VIDEO_FILE_EXTENSIONS:
out_dict["external_files"].append(path)
else:
lgr.warning(
Expand All @@ -419,6 +429,48 @@ def _get_image_series(nwb: pynwb.NWBFile) -> list[dict]:
", ".join(VIDEO_FILE_EXTENSIONS),
)
out.append(out_dict)
out.extend(_get_external_images(nwb))
return out


def _get_external_images(nwb: pynwb.NWBFile) -> list[dict]:
"""Retrieves all ExternalImage metadata from an open nwb file.

An `ExternalImage` holds a single path or URL in ``data`` rather than a list in
``external_file``, and it sits inside an `Images` container rather than directly in a module,
so the whole file is walked instead of the top level of the video modules.

Parameters
----------
nwb: pynwb.NWBFile

Returns
-------
out: list[dict]
list of dicts : [{id: <ExternalImage uuid>, name: <ExternalImage name>,
external_files=[ExternalImage.data], field: "data"}]
"""
try:
from pynwb.base import ExternalImage
except ImportError:
# `ExternalImage` was added in pynwb 3.1.0; nothing to collect on an older one.
return []

out = []
for ob in nwb.objects.values():
if not isinstance(ob, ExternalImage) or ob.data is None:
continue
data = ob.data.decode() if isinstance(ob.data, bytes) else str(ob.data)
if (path := PurePosixPath(data)).suffix.lower() not in IMAGE_FILE_EXTENSIONS:
lgr.warning(
"external image %s should be one of: %s",
data,
", ".join(IMAGE_FILE_EXTENSIONS),
)
continue
out.append(
dict(id=ob.object_id, name=ob.name, external_files=[path], field="data")
)
return out


Expand All @@ -443,28 +495,82 @@ def rename_nwb_external_files(metadata: list[dict], dandiset_path: str) -> None:
)
return
dandiset_nwbfile_path = op.join(dandiset_path, meta["dandi_path"])
with NWBHDF5IO(dandiset_nwbfile_path, mode="r+", load_namespaces=True) as io:
nwb = io.read()
for ext_file_dict in meta["external_file_objects"]:
# retrieve nwb neurodata object of the given object id:
container_list = [
child
for child in nwb.children
if ext_file_dict["id"] == child.object_id
]
if len(container_list) == 0:
continue
else:
container = container_list[0]
# rename all external files:
for no, (name_old, name_new) in enumerate(
zip(
ext_file_dict["external_files"],
ext_file_dict["external_files_renamed"],
)
):
if not is_url(str(name_old)):
container.external_file[no] = str(name_new)
image_series = [
d
for d in meta["external_file_objects"]
if d.get("field", "external_file") == "external_file"
]
external_images = [
d for d in meta["external_file_objects"] if d.get("field") == "data"
]
if image_series:
with NWBHDF5IO(
dandiset_nwbfile_path, mode="r+", load_namespaces=True
) as io:
nwb = io.read()
for ext_file_dict in image_series:
# retrieve nwb neurodata object of the given object id:
container_list = [
child
for child in nwb.children
if ext_file_dict["id"] == child.object_id
]
if len(container_list) == 0:
continue
else:
container = container_list[0]
# rename all external files:
for no, (name_old, name_new) in enumerate(
zip(
ext_file_dict["external_files"],
ext_file_dict["external_files_renamed"],
)
):
if not is_url(str(name_old)):
container.external_file[no] = str(name_new)
if external_images:
_rename_external_images(dandiset_nwbfile_path, external_images)


def _rename_external_images(nwbfile_path: str, external_images: list[dict]) -> None:
"""Rewrites the ``data`` of the given `ExternalImage` objects in an NWB file on disk.

`ExternalImage.data` is a scalar string rather than the array `ImageSeries.external_file` is,
so assigning through the read container does not reach the file and the dataset is written
directly instead. Objects are matched on the ``object_id`` attribute, since an `ExternalImage`
sits inside an `Images` container at a path this function is not told.

Parameters
----------
nwbfile_path: str
full path of the NWB file to rewrite
external_images: list[dict]
the ``field == "data"`` entries of ``metadata["external_file_objects"]``
"""
# Avoid a module level dependency on h5py for a path most callers never take:
import h5py

renames = {}
for ext_file_dict in external_images:
for name_old, name_new in zip(
ext_file_dict["external_files"], ext_file_dict["external_files_renamed"]
):
if not is_url(str(name_old)):
renames[ext_file_dict["id"]] = str(name_new)
if not renames:
return

def rename_if_matched(_name: str, obj: Any) -> None:
if not isinstance(obj, h5py.Dataset):
return
object_id = obj.attrs.get("object_id")
if isinstance(object_id, bytes):
object_id = object_id.decode()
if object_id in renames:
obj[()] = renames[object_id]

with h5py.File(nwbfile_path, "r+") as f:
f.visititems(rename_if_matched)


@validate_cache.memoize_path
Expand Down
Loading
Loading