From ff81a68b914f6096bd87960030d112e04fbe2e34 Mon Sep 17 00:00:00 2001 From: Jasper Phelps Date: Thu, 9 Jul 2026 14:17:03 +0200 Subject: [PATCH] Recognize `camera_RF.mp4`-type video names, case-insensitively A [[sources]] filename may now be a list of alternate globs, tried in order (first that resolves footage wins), and every glob is matched case-insensitively. The default config gives each source its self-describing anatomical file (camera_RH.mp4, camera_F.mp4, ...) with the DeepFly3D positional index (camera_0 .. camera_6) as a fallback --- src/deeperfly/config.py | 49 +++++++-- src/deeperfly/data/default_config.toml | 21 ++-- src/deeperfly/pose2d/pathways.py | 16 ++- src/deeperfly/recordings.py | 142 ++++++++++++++++++++----- 4 files changed, 184 insertions(+), 44 deletions(-) diff --git a/src/deeperfly/config.py b/src/deeperfly/config.py index 1a2e7d8..84ce969 100644 --- a/src/deeperfly/config.py +++ b/src/deeperfly/config.py @@ -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 -------------------------------------------------------- @@ -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 ------------------------------------------------- diff --git a/src/deeperfly/data/default_config.toml b/src/deeperfly/data/default_config.toml index ecafc6d..a39b685 100644 --- a/src/deeperfly/data/default_config.toml +++ b/src/deeperfly/data/default_config.toml @@ -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) diff --git a/src/deeperfly/pose2d/pathways.py b/src/deeperfly/pose2d/pathways.py index ce91280..72a954f 100644 --- a/src/deeperfly/pose2d/pathways.py +++ b/src/deeperfly/pose2d/pathways.py @@ -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) @@ -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: @@ -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 diff --git a/src/deeperfly/recordings.py b/src/deeperfly/recordings.py index 256a125..e6b730a 100644 --- a/src/deeperfly/recordings.py +++ b/src/deeperfly/recordings.py @@ -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 ------- @@ -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]: @@ -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 ---------- @@ -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() @@ -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