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
30 changes: 28 additions & 2 deletions src/jabs/project/parallel_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from jabs.core.enums import CacheFormat
from jabs.pose_estimation import open_pose_file
from jabs.video_reader import VideoReader
from jabs.video_reader.utilities import get_fps
from jabs.video_reader.utilities import get_fps_and_nframes

from .track_labels import TrackLabels
from .video_labels import VideoLabels
Expand Down Expand Up @@ -212,13 +212,39 @@ def _apply_macos_fork_lapack_workaround() -> None:
fe.feature_base_class._use_numpy_detrend = True


def _warn_on_frame_count_mismatch(
video: str, video_frame_count: int, pose_frame_count: int
) -> None:
"""Log a warning when a video and its pose file disagree on frame count.
The video/pose frame-count check is deferred from project load. Feature
extraction and predictions are indexed to pose frames, so a mismatch does
not corrupt them; this surfaces the discrepancy for headless/batch runs
where no video is opened interactively.
Args:
video: Video filename, used in the log message.
video_frame_count: Frame count read from the video file.
pose_frame_count: Frame count from the pose file.
"""
if video_frame_count != pose_frame_count:
logger.warning(
"%s: video frame count (%d) does not match pose file frame count (%d); "
"features and predictions are pose-indexed and unaffected",
video,
video_frame_count,
pose_frame_count,
)


def _open_pose_and_labels(
job: _BaseFeatureLoadJobSpec,
) -> "tuple[PoseEstimation, VideoLabels | None, float]":
"""Set up the macOS workaround and open pose + label resources for a job."""
_apply_macos_fork_lapack_workaround()
pose_est = open_pose_file(job["pose_path"], job["cache_dir"])
fps = get_fps(str(job["video_path"]))
fps, video_frame_count = get_fps_and_nframes(str(job["video_path"]))
_warn_on_frame_count_mismatch(job["video"], video_frame_count, pose_est.num_frames)
labels_obj = _load_video_labels(job["annotations_path"], pose_est)
return pose_est, labels_obj, fps

Expand Down
8 changes: 6 additions & 2 deletions src/jabs/project/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ class Project:
project_path: Path to the project directory.
process_pool: Optional shared ProcessPoolManager for feature extraction. If None, runs single-threaded.
use_cache: Whether to use cached data.
enable_video_check: Whether to check for video file validity.
enable_video_check: Opt-in up-front validation that every video's frame
count matches its pose file. When True, opens every video file at
load time (expensive). When False (the default), this check is
deferred to when a video is opened (GUI) and to the feature-extraction
workers (headless), which warn on a mismatch.
enable_session_tracker: Whether to enable session tracking for this project.
validate_project_dir: Whether to validate the project directory structure on creation.
video_dir: Optional directory containing video files. Defaults to `project_path`.
Expand All @@ -106,7 +110,7 @@ def __init__(
project_path,
process_pool: "ProcessPoolManager | None" = None,
use_cache=True,
enable_video_check=True,
enable_video_check=False,
enable_session_tracker=True,
validate_project_dir=True,
video_dir: Path | None = None,
Expand Down
7 changes: 4 additions & 3 deletions src/jabs/project/video_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ class VideoManager:
Args:
paths: Object containing project directory paths.
settings_manager: Manages project settings and metadata.
enable_video_check: Whether to validate that video and pose file frame
counts match. Defaults to True.
enable_video_check: Opt-in up-front validation that video and pose file
frame counts match; when True, every video file is opened. Defaults
to False, which defers the check to when a video is opened.
scan_results: Per-video metadata collected by
:func:`~jabs.project.parallel_workers.scan_video_metadata`, keyed
by video filename. Every video in the project directory must have an
Expand All @@ -47,7 +48,7 @@ def __init__(
self,
paths: ProjectPaths,
settings_manager: SettingsManager,
enable_video_check: bool = True,
enable_video_check: bool = False,
*,
scan_results: "dict[str, VideoScanResult]",
):
Expand Down
16 changes: 10 additions & 6 deletions src/jabs/scripts/initialize_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import json
import logging
import os
from multiprocessing import Pool
from multiprocessing.pool import Pool
from pathlib import Path

import click
Expand Down Expand Up @@ -177,15 +177,14 @@ def _load_metadata(metadata_path: Path | None) -> dict | None:
try:
metadata = json.loads(metadata_path.read_text())
validate_metadata(metadata)
return metadata
except json.JSONDecodeError as e:
_exit_with_message(f"Error reading metadata file {metadata_path}: {e}")
except OSError as e:
_exit_with_message(f"Error opening metadata file {metadata_path}: {e}")
except ValidationError as e:
_exit_with_message(f"Metadata file {metadata_path} is not valid: {e.message}")

return metadata


def _apply_project_metadata(project: jabs.project.Project, metadata: dict | None) -> None:
"""Merge or replace project metadata when requested."""
Expand Down Expand Up @@ -253,7 +252,12 @@ def run_initialize_project(
videos = VideoManager.get_videos(project_dir)
metadata = _load_metadata(metadata_path)

project = jabs.project.Project(project_dir, enable_session_tracker=False)
# jabs-init performs a full up-front validation of the project, including
# confirming each video and its pose file agree on frame count, so it fails
# fast on an inconsistent project before any features are generated.
project = jabs.project.Project(
project_dir, enable_video_check=True, enable_session_tracker=False
)
distance_unit = project.feature_manager.distance_unit
_apply_project_metadata(project, metadata)

Expand Down Expand Up @@ -283,8 +287,8 @@ def run_initialize_project(
# pose file and video frame number mismatch, etc

def validation_job_producer():
for video in videos:
yield {"video": video, "project_dir": project_dir}
for video_name in videos:
yield {"video": video_name, "project_dir": project_dir}

# do work in parallel (not really necessary for this test, but we already
# have the worker pool for generating features)
Expand Down
44 changes: 44 additions & 0 deletions src/jabs/ui/main_window/central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,33 @@ def set_project(self, project: Project) -> None:
self._search_bar_widget.update_project(project)
self._update_timeline_search_results()

@staticmethod
def _frame_count_mismatch_message(
video_name: str, video_frame_count: int, pose_frame_count: int
) -> str | None:
"""Build a warning message when video and pose frame counts differ.

The project-load frame-count check is deferred, so it runs here when a
video is opened. Labels and features are indexed to pose frames while the
player displays video frames, so a mismatch means the displayed frame may
not line up with labels for this video.

Args:
video_name: Name of the video being opened.
video_frame_count: Frame count reported by the video player.
pose_frame_count: Frame count from the pose file.

Returns:
A user-facing warning message, or None when the counts match.
"""
if video_frame_count == pose_frame_count:
return None
return (
f"{video_name} has {video_frame_count} video frames but its pose file "
f"has {pose_frame_count}. Labels are indexed to pose frames, so the "
f"displayed frame may not align with labels for this video."
)

def load_video(self, path: Path) -> None:
"""load a new video file into self._player_widget

Expand Down Expand Up @@ -409,6 +436,23 @@ def load_video(self, path: Path) -> None:

self._player_widget.load_video(path, self._pose_est, self._labels)

# Frame-count check deferred from project load: warn (do not block)
# if the video and its pose file disagree on frame count, since labels
# and features are pose-indexed but the player shows video frames. The
# dialog is scheduled on the next event-loop turn so it does not pause
# the rest of load_video; the video finishes loading, then the warning
# appears over it.
mismatch_message = self._frame_count_mismatch_message(
path.name, self._player_widget.num_frames, self._pose_est.num_frames
)
if mismatch_message is not None:
QtCore.QTimer.singleShot(
0,
lambda: MessageDialog.warning(
self, "Video / pose frame count mismatch", mismatch_message
),
)

# load saved predictions for this video
(
self._predictions,
Expand Down
26 changes: 26 additions & 0 deletions src/jabs/video_reader/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,29 @@ def get_fps(video_path: str):
raise OSError(f"unable to open {video_path}")

return round(stream.get(cv2.CAP_PROP_FPS))


def get_fps_and_nframes(video_path: str) -> tuple[int, int]:
"""Get the frames per second and frame count from a video in a single open.

Reads both properties from one ``cv2.VideoCapture`` handle so callers that
already need the FPS can obtain the frame count without a second file open.

Args:
video_path: string containing path to video file.

Returns:
Tuple of ``(fps, num_frames)`` where ``fps`` is rounded to an int.

Raises:
OSError: if unable to open the specified video.
"""
stream = cv2.VideoCapture(video_path)
try:
if not stream.isOpened():
raise OSError(f"unable to open {video_path}")
fps = round(stream.get(cv2.CAP_PROP_FPS))
num_frames = int(stream.get(cv2.CAP_PROP_FRAME_COUNT))
finally:
stream.release()
return fps, num_frames
36 changes: 36 additions & 0 deletions tests/project/test_initialize_project.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from click.testing import CliRunner

import jabs.scripts.initialize_project as initialize_project
Expand Down Expand Up @@ -146,3 +147,38 @@ def test_jabs_init_click_rejects_invalid_process_count(tmp_path):
result = runner.invoke(initialize_project.main, ["-p", "0", str(project_dir)])

assert result.exit_code == 2


def test_run_initialize_project_enables_video_frame_check(tmp_path, monkeypatch):
"""jabs-init builds the Project with the up-front video-frame check enabled.

Phase 1 (KLAUS-505) made ``enable_video_check`` default to False, so this
guards that batch initialization still validates that video and pose files
agree on frame count.
"""
project_dir = tmp_path / "project"
project_dir.mkdir()

captured: dict = {}

class _StopAfterProject(Exception):
pass

def fake_project(*args, **kwargs):
captured.update(kwargs)
raise _StopAfterProject

monkeypatch.setattr("jabs.project.Project", fake_project)

with pytest.raises(_StopAfterProject):
initialize_project.run_initialize_project(
force=False,
processes=1,
window_sizes=(),
force_pixel_distances=False,
metadata_path=None,
skip_feature_generation=True,
project_dir=project_dir,
)

assert captured.get("enable_video_check") is True
25 changes: 23 additions & 2 deletions tests/project/test_parallel_workers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for parallel_workers — scan_video_metadata and _get_identity_count."""

import logging
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
Expand All @@ -13,6 +14,7 @@
VideoScanJobSpec,
VideoScanResult,
_get_identity_count,
_warn_on_frame_count_mismatch,
collect_multiclass_labeled_features,
scan_video_metadata,
)
Expand All @@ -26,6 +28,7 @@ class _MockPose:
def __init__(self, mask: np.ndarray):
self._mask = mask
self.identities = [0]
self.num_frames = int(mask.shape[0])

def identity_mask(self, identity: int) -> np.ndarray:
assert identity == 0
Expand Down Expand Up @@ -64,7 +67,9 @@ def merge_window_features(window_features: dict[str, np.ndarray]) -> dict[str, n
return window_features

monkeypatch.setattr("jabs.project.parallel_workers.open_pose_file", lambda *_: pose)
monkeypatch.setattr("jabs.project.parallel_workers.get_fps", lambda *_: 30)
monkeypatch.setattr(
"jabs.project.parallel_workers.get_fps_and_nframes", lambda *_: (30, pose.num_frames)
)
monkeypatch.setattr("jabs.project.parallel_workers._load_video_labels", lambda *_: labels)
monkeypatch.setattr("jabs.project.parallel_workers.fe.IdentityFeatures", _FakeIdentityFeatures)

Expand Down Expand Up @@ -134,7 +139,9 @@ def test_collect_labeled_features_multiclass_requires_behavior_names(
labels.get_track_labels("0", MULTICLASS_NONE_BEHAVIOR).label_behavior(0, 0)
pose = _MockPose(mask=np.array([1, 1], dtype=np.uint8))
monkeypatch.setattr("jabs.project.parallel_workers.open_pose_file", lambda *_: pose)
monkeypatch.setattr("jabs.project.parallel_workers.get_fps", lambda *_: 30)
monkeypatch.setattr(
"jabs.project.parallel_workers.get_fps_and_nframes", lambda *_: (30, pose.num_frames)
)
monkeypatch.setattr("jabs.project.parallel_workers._load_video_labels", lambda *_: labels)

with pytest.raises(ValueError, match="behavior_names is required"):
Expand Down Expand Up @@ -381,6 +388,20 @@ def test_video_manager_scan_results_frame_mismatch_raises(tmp_path):
# ---------------------------------------------------------------------------


def test_warn_on_frame_count_mismatch_logs_warning(caplog):
"""A video/pose frame-count mismatch emits a warning naming the video."""
with caplog.at_level(logging.WARNING):
_warn_on_frame_count_mismatch("video1.avi", 100, 90)
assert any("video1.avi" in m and "does not match" in m for m in caplog.messages)


def test_warn_on_frame_count_mismatch_silent_when_equal(caplog):
"""No warning is emitted when the frame counts match."""
with caplog.at_level(logging.WARNING):
_warn_on_frame_count_mismatch("video1.avi", 100, 100)
assert not any("does not match" in m for m in caplog.messages)


def test_feature_manager_with_scan_results_no_hdf5_open(tmp_path):
"""FeatureManager.__initialize_pose_data skips HDF5 opens when scan_results provided."""
from jabs.project.feature_manager import FeatureManager
Expand Down
19 changes: 17 additions & 2 deletions tests/project/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,24 @@ def test_no_saved_video_labels(project_with_data):


def test_bad_video_file(project_with_data):
"""test loading a video file that doesn't exist raises ValueError"""
"""Opt-in up-front video check raises IOError when a video can't be opened."""
with pytest.raises(IOError), hide_stderr():
_ = Project(Path("test_project_with_data"), enable_session_tracker=False)
_ = Project(
Path("test_project_with_data"),
enable_video_check=True,
enable_session_tracker=False,
)


def test_load_defers_video_frame_check_by_default(project_with_data):
"""By default the video frame-count check is deferred.

The fixture's ``.avi`` files are empty stubs that cannot be opened, so the
opt-in path raises IOError (see ``test_bad_video_file``). With the default
(deferred) behavior no video file is opened, so the project loads cleanly.
"""
project = Project(Path("test_project_with_data"), enable_session_tracker=False)
assert set(project.video_manager.videos) == {"test_file_1.avi", "test_file_2.avi"}


def test_min_pose_version(project_with_data):
Expand Down
14 changes: 14 additions & 0 deletions tests/ui/test_central_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ def test_included_project_bout_totals_handles_none_counts():
assert CentralWidget._included_project_bout_totals(stub) == (0, 0)


def test_frame_count_mismatch_message_none_when_equal():
"""Matching video/pose frame counts produce no warning message."""
assert CentralWidget._frame_count_mismatch_message("v.avi", 100, 100) is None


def test_frame_count_mismatch_message_reports_counts():
"""A mismatch yields a message naming the video and both frame counts."""
msg = CentralWidget._frame_count_mismatch_message("v.avi", 100, 90)
assert msg is not None
assert "v.avi" in msg
assert "100" in msg
assert "90" in msg


# ---------------------------------------------------------------------------
# Single-video classification (context-menu path)
# ---------------------------------------------------------------------------
Expand Down
Loading