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
49 changes: 42 additions & 7 deletions src/deeperfly/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,37 @@ def _params(data: dict, path: tuple[str, ...], cls, *, ignore: frozenset = froze
return cls(**{k: v for k, v in sub.items() if k in fields})


def _source_filename(filename, name: str) -> str | list[str]:
"""Validate a ``[[sources]]`` ``filename`` value (a glob or list of globs).

Parameters
----------
filename
The raw ``filename`` value from the config (a string, or a list of
strings -- alternate globs tried in order).
name
The source's name, for the error message.

Returns
-------
str or list of str
The validated ``filename`` value, unchanged.

Raises
------
ValueError
If ``filename`` is not a string or a list of strings.
"""
if isinstance(filename, str):
return filename
if isinstance(filename, list) and all(isinstance(f, str) for f in filename):
return filename
raise ValueError(
f"[[sources]] {name!r} 'filename' must be a string or list of strings, "
f"got {filename!r}"
)


# -- the Config class --------------------------------------------------------


Expand Down Expand Up @@ -432,31 +463,35 @@ def camera_table(self) -> tuple[dict, dict]:
defaults = cams.pop("defaults", {})
return defaults, cams

def source_patterns(self) -> dict[str, str]:
def source_patterns(self) -> dict[str, str | list[str]]:
"""Map each footage source to its glob (``[[sources]]`` ``name`` -> ``filename``).

Read directly from the ``[[sources]]`` table (without building the whole
detection plan) so recording discovery stays cheap. A source with no
``filename`` key uses its own name as the glob pattern.
``filename`` key uses its own name as the glob pattern. ``filename`` may be
a single glob or a list of alternate globs tried in order (the first that
resolves footage wins), so a source can name both its anatomical file and a
legacy fallback (``["camera_RH.mp4", "camera_0.mp4"]``).

Returns
-------
dict of str to str
``source_name -> footage glob`` in config order.
dict of str to (str or list of str)
``source_name -> footage glob(s)`` in config order.

Raises
------
ValueError
If a source entry has no string ``name``.
If a source entry has no string ``name``, or its ``filename`` is not a
string or list of strings.
"""
out: dict[str, str] = {}
out: dict[str, str | list[str]] = {}
for s in self.data.get("sources", []) or []:
name = s.get("name")
if not isinstance(name, str):
raise ValueError(
f"[[sources]] entry needs a string 'name', got {name!r}"
)
out[name] = s.get("filename", name)
out[name] = _source_filename(s.get("filename", name), name)
return out

# -- snapshot round-trip -------------------------------------------------
Expand Down
21 changes: 13 additions & 8 deletions src/deeperfly/data/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,32 @@
# a view's frames from it. Decoupled from the cameras and pathways that reference
# it by name, so one source can feed several pathways.
# ===========================================================================
[[sources]] # name -> footage glob (a file, prefix, or wildcard)
# `filename` is a footage glob (a file, prefix, or wildcard), or a list of
# alternates tried in order -- the first that resolves footage wins. It is matched
# case-insensitively, so `camera_RH.mp4` finds a `CAMERA_rh.MP4`. Each source below
# names its self-describing anatomical file (RH = right hind, F = front, ...) and
# falls back to the DeepFly3D positional index (`camera_0` .. `camera_6`).
[[sources]]
name = "vid_rh"
filename = "camera_0.mp4"
filename = ["camera_RH.mp4", "camera_0.mp4"]
[[sources]]
name = "vid_rm"
filename = "camera_1.mp4"
filename = ["camera_RM.mp4", "camera_1.mp4"]
[[sources]]
name = "vid_rf"
filename = "camera_2.mp4"
filename = ["camera_RF.mp4", "camera_2.mp4"]
[[sources]]
name = "vid_f"
filename = "camera_3.mp4"
filename = ["camera_F.mp4", "camera_3.mp4"]
[[sources]]
name = "vid_lf"
filename = "camera_4.mp4"
filename = ["camera_LF.mp4", "camera_4.mp4"]
[[sources]]
name = "vid_lm"
filename = "camera_5.mp4"
filename = ["camera_LM.mp4", "camera_5.mp4"]
[[sources]]
name = "vid_lh"
filename = "camera_6.mp4"
filename = ["camera_LH.mp4", "camera_6.mp4"]

[io.image]
workers = 0 # decode threads (0 = auto, one per CPU)
Expand Down
16 changes: 12 additions & 4 deletions src/deeperfly/pose2d/pathways.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class Source:
"""A named footage source: its glob pattern (``[[sources]]`` ``filename``)."""

name: str
pattern: str
pattern: str | list[str]


@dataclass(frozen=True)
Expand Down Expand Up @@ -167,8 +167,12 @@ class DetectionPlan:
def n_views(self) -> int:
return len(self.view_names)

def source_patterns(self) -> dict[str, str]:
"""``source name -> footage glob`` in config order."""
def source_patterns(self) -> dict[str, str | list[str]]:
"""``source name -> footage glob(s)`` in config order.

A value may be a single glob or a list of alternate globs (see
:meth:`deeperfly.config.Config.source_patterns`).
"""
return {s.name: s.pattern for s in self.sources}

def model_for(self, pathway: Pathway) -> ModelSpec:
Expand Down Expand Up @@ -269,7 +273,11 @@ def _parse_sources(raw) -> list[Source]:
if name in seen:
raise ValueError(f"[[sources]] has a duplicate name {name!r}")
seen.add(name)
out.append(Source(name=name, pattern=s.get("filename", name)))
from ..config import _source_filename

out.append(
Source(name=name, pattern=_source_filename(s.get("filename", name), name))
)
return out


Expand Down
142 changes: 117 additions & 25 deletions src/deeperfly/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,110 @@ def _camera_glob(pattern: str) -> str:
return f"{pattern}*"


def camera_files(root: Path, pattern: str) -> list[Path]:
def _case_insensitive_glob(pattern: str) -> str:
"""Rewrite a glob so its ASCII letters match either case.

Wraps each ASCII letter that is not already inside a ``[...]`` character class
in a two-case class (``h`` -> ``[hH]``), leaving wildcards (``* ? [ ]``) and
path separators untouched. So ``camera_RH.mp4`` matches a ``CAMERA_rh.MP4`` on
a case-sensitive filesystem while ``camera_0/*`` still traverses a
subdirectory. Matching an anatomical name like ``camera_RH.mp4`` is thus
robust to however the acquisition capitalized ``CAMERA``, ``RH``, or ``MP4``.

Parameters
----------
pattern
A filename glob (the output of :func:`_camera_glob`).

Returns
-------
str
An equivalent glob whose letters are case-insensitive.
"""
out: list[str] = []
in_class = False
for char in pattern:
if char == "[":
in_class = True
elif char == "]":
in_class = False
if char.isascii() and char.isalpha() and not in_class:
out.append(f"[{char.lower()}{char.upper()}]")
else:
out.append(char)
return "".join(out)


def _as_alternates(pattern: str | list[str]) -> list[str]:
"""A source's ``filename`` as an ordered list of alternate glob patterns.

A bare string is a single-element list; a list is used as-is. The alternates
are tried in order, the first that resolves any footage winning -- so a source
can name both its anatomical file and a legacy fallback
(``["camera_RH.mp4", "camera_0.mp4"]``) and match whichever the recording
actually holds.

Parameters
----------
pattern
A source's ``filename`` value (a glob string, or a list of them).

Returns
-------
list of str
The alternate glob patterns, in priority order.
"""
return [pattern] if isinstance(pattern, str) else list(pattern)


def _raw_matches(root: Path, pattern: str | list[str]) -> list[Path]:
"""The first alternate's raw (any-file) matches under ``root``, case-insensitively.

Unlike :func:`camera_files`, this keeps every matched file (not just footage),
so a caller can tell "matched, but not footage" from "matched nothing".

Parameters
----------
root
The recording directory to glob inside.
pattern
The source's ``filename`` value (see :func:`_as_alternates`).

Returns
-------
list of Path
The files matched by the first alternate that matches any file (else empty).
"""
for alternate in _as_alternates(pattern):
files = [
p
for p in root.glob(_case_insensitive_glob(_camera_glob(alternate)))
if p.is_file()
]
if files:
return files
return []


def camera_files(root: Path, pattern: str | list[str]) -> list[Path]:
"""A camera's footage files under ``root`` matching its ``input`` ``pattern``.

Globs ``pattern`` (see :func:`_camera_glob`), keeps files with a known footage
extension, and -- when several extensions match -- keeps the highest-priority
one. Naturally sorted. Video footage is a single file, so several matching
videos keep only the first (warned); images stay as the whole sequence. Empty
when nothing footage-like matches, so the caller can treat the camera as absent.
Tries each of ``pattern``'s alternates (see :func:`_as_alternates`) in order,
returning the first that resolves footage. For a given alternate: globs it
case-insensitively (see :func:`_camera_glob`, :func:`_case_insensitive_glob`),
keeps files with a known footage extension, and -- when several extensions
match -- keeps the highest-priority one. Naturally sorted. Video footage is a
single file, so several matching videos keep only the first (warned); images
stay as the whole sequence. Empty when no alternate resolves footage, so the
caller can treat the camera as absent.

Parameters
----------
root
The recording directory to glob inside.
pattern
The camera's ``input`` glob (see :func:`_camera_glob`).
The camera's ``input`` glob, or a list of alternates (see
:func:`_as_alternates`).

Returns
-------
Expand All @@ -117,16 +206,20 @@ def camera_files(root: Path, pattern: str) -> list[Path]:
from natsort import natsorted

exts = _footage_exts()
files = [
p
for p in root.glob(_camera_glob(pattern))
if p.is_file() and p.suffix.lower() in exts
]
present = {p.suffix.lower() for p in files}
if len(present) > 1:
keep = min(present, key=exts.index)
files = [p for p in files if p.suffix.lower() == keep]
return _first_if_video(root, pattern, natsorted(files))
for alternate in _as_alternates(pattern):
files = [
p
for p in root.glob(_case_insensitive_glob(_camera_glob(alternate)))
if p.is_file() and p.suffix.lower() in exts
]
if not files:
continue
present = {p.suffix.lower() for p in files}
if len(present) > 1:
keep = min(present, key=exts.index)
files = [p for p in files if p.suffix.lower() == keep]
return _first_if_video(root, alternate, natsorted(files))
return []


def _first_if_video(root: Path, name: str, files: list[Path]) -> list[Path]:
Expand Down Expand Up @@ -163,10 +256,12 @@ def _first_if_video(root: Path, name: str, files: list[Path]) -> list[Path]:
return files


def source_patterns(config: Config) -> dict[str, str]:
def source_patterns(config: Config) -> dict[str, str | list[str]]:
"""``source-name -> footage glob`` (the ``[[sources]]`` ``input`` key), in order.

A source with no ``input`` entry defaults to its own name as the pattern.
A source with no ``input`` entry defaults to its own name as the pattern. A
value may be a single glob or a list of alternate globs tried in order (see
:func:`_as_alternates`).

Parameters
----------
Expand All @@ -175,8 +270,8 @@ def source_patterns(config: Config) -> dict[str, str]:

Returns
-------
dict of str to str
``source_name -> footage glob`` in config order.
dict of str to (str or list of str)
``source_name -> footage glob(s)`` in config order.
"""
return config.source_patterns()

Expand Down Expand Up @@ -381,10 +476,7 @@ def find_recording(root: Path, config: Config) -> dict[str, list[Path]] | None:
patterns = source_patterns(config)
# Raw matches (any file) per source, so "no match" is distinguishable from
# "matched, but not footage".
raw = {
name: [p for p in root.glob(_camera_glob(pat)) if p.is_file()]
for name, pat in patterns.items()
}
raw = {name: _raw_matches(root, pat) for name, pat in patterns.items()}
present = {name: ps for name, ps in raw.items() if ps}
if not present:
return None # nothing here looks like a camera's files: not a recording
Expand Down
Loading