Skip to content

Commit fb6d8df

Browse files
authored
Merge pull request #413 from KumarLabJax/feature/klaus-506-pose-attribute-cache
Cache per-video pose attributes to skip load-time pose scan (KLAUS-506)
2 parents f8c22d3 + 7d2067b commit fb6d8df

4 files changed

Lines changed: 523 additions & 1 deletion

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Persistent cache of per-video pose attributes to skip the load-time pose scan.
2+
3+
The project-load scan opens every pose HDF5 file to read a handful of small,
4+
intrinsic attributes (frame count, identity count, static objects, lixit
5+
keypoint count, cm-per-pixel flag). Those values change only when the pose file
6+
itself changes, so they are cached in ``jabs/cache/pose_attribute_cache.json``
7+
keyed by video filename and gated by a cheap ``stat`` token of the pose file. A
8+
subsequent load then rescans only the videos whose pose file is new or changed.
9+
10+
The cache is an optimization only: a missing, unreadable, or schema-mismatched
11+
cache simply triggers a full rescan, and a failed write is logged and ignored.
12+
"""
13+
14+
import json
15+
import logging
16+
from pathlib import Path
17+
18+
logger = logging.getLogger(__name__)
19+
20+
# Bump when the cached entry schema changes so stale caches are ignored wholesale.
21+
SCHEMA_VERSION = 1
22+
23+
24+
def pose_token(pose_path: Path) -> str:
25+
"""Return a cheap change-detection token for a pose file.
26+
27+
Uses a single ``stat`` (size and modification time) rather than opening or
28+
hashing the file, so computing the token stays far cheaper than the scan it
29+
guards.
30+
31+
Args:
32+
pose_path: Path to the pose HDF5 file.
33+
34+
Returns:
35+
A ``"<size>:<mtime_ns>"`` token string.
36+
"""
37+
st = pose_path.stat()
38+
return f"{st.st_size}:{st.st_mtime_ns}"
39+
40+
41+
def load(cache_path: Path | None) -> dict[str, dict]:
42+
"""Load the per-video attribute map from the cache file.
43+
44+
Args:
45+
cache_path: Path to the cache JSON file, or ``None`` when caching is
46+
disabled (``use_cache=False``).
47+
48+
Returns:
49+
Mapping of video filename to its cached entry. Returns an empty mapping
50+
when caching is disabled, or the file is missing, unreadable, or written
51+
by a different schema version.
52+
"""
53+
if cache_path is None or not cache_path.exists():
54+
return {}
55+
try:
56+
with cache_path.open("r") as f:
57+
data = json.load(f)
58+
except (OSError, ValueError):
59+
logger.warning("Could not read pose attribute cache %s; rescanning", cache_path)
60+
return {}
61+
if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION:
62+
return {}
63+
videos = data.get("videos")
64+
return videos if isinstance(videos, dict) else {}
65+
66+
67+
def save(cache_path: Path | None, videos: dict[str, dict]) -> None:
68+
"""Atomically write the per-video attribute map to the cache file.
69+
70+
A write failure is logged and swallowed: the cache is an optimization, so
71+
failing to persist it must never break project loading.
72+
73+
Args:
74+
cache_path: Path to the cache JSON file, or ``None`` when caching is
75+
disabled (in which case this is a no-op).
76+
videos: Mapping of video filename to its cached entry.
77+
"""
78+
if cache_path is None:
79+
return
80+
payload = {"schema_version": SCHEMA_VERSION, "videos": videos}
81+
tmp = cache_path.with_suffix(".json.tmp")
82+
try:
83+
cache_path.parent.mkdir(parents=True, exist_ok=True)
84+
with tmp.open("w") as f:
85+
json.dump(payload, f, indent=2, sort_keys=True)
86+
tmp.replace(cache_path)
87+
except OSError:
88+
logger.warning("Could not write pose attribute cache %s", cache_path, exc_info=True)

src/jabs/project/project.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
open_pose_file,
3434
)
3535

36+
from . import pose_attribute_cache
3637
from .feature_manager import FeatureManager
3738
from .parallel_workers import (
3839
BinaryFeatureLoadJobSpec,
@@ -61,6 +62,78 @@
6162
from jabs.core.utils.process_pool_manager import ProcessPoolManager
6263

6364

65+
def _is_int(value: object) -> bool:
66+
"""Return True for a genuine int (JSON bools are ints in Python; reject them)."""
67+
return isinstance(value, int) and not isinstance(value, bool)
68+
69+
70+
def _is_str_list(value: object) -> bool:
71+
"""Return True for a list whose every element is a string."""
72+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
73+
74+
75+
# Pose-derived fields cached per video, mapped to a predicate validating the
76+
# cached value's type (matching the corresponding VideoScanResult field). An
77+
# entry is trusted only when it carries a matching token and pose filename plus
78+
# every field present with the expected type; a parseable-but-malformed entry
79+
# (e.g. a hand-edited or corrupted cache) is treated as a miss and rescanned.
80+
_POSE_CACHE_FIELD_VALIDATORS: dict[str, Callable[[object], bool]] = {
81+
"hdf5_frame_count": _is_int,
82+
"identity_count": _is_int,
83+
"static_objects": _is_str_list,
84+
"lixit_keypoints": _is_int,
85+
"has_cm_per_pixel": lambda value: isinstance(value, bool),
86+
}
87+
88+
89+
def _pose_cache_entry_matches(entry: object, token: str, pose_file: str) -> bool:
90+
"""Return True when a cache entry is usable for a video's current pose file.
91+
92+
Validates field types as well as presence so a parseable-but-malformed entry
93+
is treated as a miss (triggering a rescan) rather than reconstructed into
94+
wrong metadata or a reconstruction-time error.
95+
"""
96+
return (
97+
isinstance(entry, dict)
98+
and entry.get("token") == token
99+
and entry.get("pose_file") == pose_file
100+
and all(
101+
field in entry and validator(entry[field])
102+
for field, validator in _POSE_CACHE_FIELD_VALIDATORS.items()
103+
)
104+
)
105+
106+
107+
def _scan_result_from_pose_cache(video: str, entry: dict) -> VideoScanResult:
108+
"""Reconstruct a VideoScanResult from a cached entry.
109+
110+
``video_frame_count`` is left ``None``: video frame counts are validated on
111+
demand rather than at load, so they are never cached.
112+
"""
113+
return VideoScanResult(
114+
video=video,
115+
hdf5_frame_count=entry["hdf5_frame_count"],
116+
video_frame_count=None,
117+
identity_count=entry["identity_count"],
118+
static_objects=list(entry["static_objects"]),
119+
lixit_keypoints=entry["lixit_keypoints"],
120+
has_cm_per_pixel=entry["has_cm_per_pixel"],
121+
)
122+
123+
124+
def _pose_cache_entry(result: VideoScanResult, pose_file: str, token: str) -> dict:
125+
"""Build a cache entry from a fresh scan result."""
126+
return {
127+
"token": token,
128+
"pose_file": pose_file,
129+
"hdf5_frame_count": result["hdf5_frame_count"],
130+
"identity_count": result["identity_count"],
131+
"static_objects": list(result["static_objects"]),
132+
"lixit_keypoints": result["lixit_keypoints"],
133+
"has_cm_per_pixel": result["has_cm_per_pixel"],
134+
}
135+
136+
64137
class Project:
65138
"""Represents a JABS project, managing data, settings, and operations for a project directory.
66139
@@ -215,18 +288,99 @@ def _run_video_scan(
215288
if not jobs:
216289
return {}
217290

291+
# The opt-in up-front check reads video frame counts, which are not
292+
# cached, so it always performs a full scan.
293+
if enable_video_check:
294+
return self._scan_jobs(jobs, process_pool)
295+
296+
return self._run_cached_video_scan(jobs, process_pool)
297+
298+
@staticmethod
299+
def _scan_jobs(
300+
jobs: list[VideoScanJobSpec],
301+
process_pool: "ProcessPoolManager | None",
302+
) -> dict[str, VideoScanResult]:
303+
"""Run scan jobs in parallel when a pool is available, else sequentially.
304+
305+
Args:
306+
jobs: Scan jobs to execute.
307+
process_pool: Optional shared pool; when ``None`` the jobs run
308+
sequentially in the calling process.
309+
310+
Returns:
311+
Mapping from video filename to its scan result.
312+
"""
218313
if process_pool is not None:
219314
future_to_video = {
220315
process_pool.submit(scan_video_metadata, job): job["video"] for job in jobs
221316
}
222317
results: dict[str, VideoScanResult] = {}
223318
for future in as_completed(future_to_video):
224-
result: VideoScanResult = future.result()
319+
try:
320+
result: VideoScanResult = future.result()
321+
except Exception:
322+
logger.error(
323+
"Failed to scan pose metadata for %s",
324+
future_to_video[future],
325+
exc_info=True,
326+
)
327+
raise
225328
results[result["video"]] = result
226329
return results
227330

228331
return {job["video"]: scan_video_metadata(job) for job in jobs}
229332

333+
def _run_cached_video_scan(
334+
self,
335+
jobs: list[VideoScanJobSpec],
336+
process_pool: "ProcessPoolManager | None",
337+
) -> dict[str, VideoScanResult]:
338+
"""Scan only new/changed pose files, reusing cached attributes otherwise.
339+
340+
Pose attributes are intrinsic to the pose file, so an entry whose cached
341+
``stat`` token still matches is reused without opening the file. Only
342+
videos with a missing or stale entry are scanned; the cache is then
343+
rewritten when anything changed (a rescan happened, or the set of videos
344+
differs from what was cached).
345+
346+
Args:
347+
jobs: Scan jobs for every video with a locatable pose file.
348+
process_pool: Optional shared pool for parallelizing the scan of the
349+
uncached videos.
350+
351+
Returns:
352+
Mapping from video filename to its scan result.
353+
"""
354+
cache_path = self._paths.pose_attribute_cache_file
355+
cached = pose_attribute_cache.load(cache_path)
356+
357+
results: dict[str, VideoScanResult] = {}
358+
tokens: dict[str, str] = {}
359+
to_scan: list[VideoScanJobSpec] = []
360+
for job in jobs:
361+
video = job["video"]
362+
token = pose_attribute_cache.pose_token(job["pose_path"])
363+
tokens[video] = token
364+
entry = cached.get(video)
365+
if _pose_cache_entry_matches(entry, token, job["pose_path"].name):
366+
results[video] = _scan_result_from_pose_cache(video, entry)
367+
else:
368+
to_scan.append(job)
369+
370+
if to_scan:
371+
results.update(self._scan_jobs(to_scan, process_pool))
372+
373+
updated = {
374+
job["video"]: _pose_cache_entry(
375+
results[job["video"]], job["pose_path"].name, tokens[job["video"]]
376+
)
377+
for job in jobs
378+
}
379+
if to_scan or set(updated) != set(cached):
380+
pose_attribute_cache.save(cache_path, updated)
381+
382+
return results
383+
230384
def _validate_pose_files(self):
231385
"""Ensure all videos have corresponding pose files."""
232386
err = False

src/jabs/project/project_paths.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ def cache_dir(self) -> Path | None:
8585
"""Get the path to the cache directory."""
8686
return self._cache_dir
8787

88+
@property
89+
def pose_attribute_cache_file(self) -> Path | None:
90+
"""Get the path to the per-video pose-attribute cache file.
91+
92+
Returns ``None`` when the project has no cache directory
93+
(``use_cache=False``), in which case pose attributes are not persisted
94+
and every load performs a full pose scan.
95+
"""
96+
return self._cache_dir / "pose_attribute_cache.json" if self._cache_dir else None
97+
8898
@property
8999
def session_dir(self) -> Path:
90100
"""Get the path to the session directory."""

0 commit comments

Comments
 (0)