Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a988942
refactor main_menu.py into a module with multiple files
gbeane Jan 6, 2026
97b474e
fix import in __main__.py
gbeane Jan 6, 2026
38f1ef6
cleanup some imports and comments
gbeane Jan 6, 2026
1dc1171
cleanup some imports
gbeane Jan 6, 2026
20815e0
rename some methods
gbeane Jan 6, 2026
c4b0637
remove empty __all__ from __init__.py
gbeane Jan 6, 2026
08b6558
fix a few menu related bugs
gbeane Jan 6, 2026
c63cc57
Apply suggestion from @Copilot
gbeane Jan 6, 2026
34bf9f8
Revert "fix a few menu related bugs"
gbeane Jan 6, 2026
65d5a12
prevent currentIndexChanged from firing when widow size selection ele…
gbeane Jan 6, 2026
6e83be5
fix some bugs and change how the ProcessPoolExecutor is managed (no l…
gbeane Jan 6, 2026
ae0e583
Update src/jabs/ui/main_window/menu_handlers.py
gbeane Jan 6, 2026
6831f59
Update src/jabs/ui/main_window/main_window.py
gbeane Jan 6, 2026
87c49b4
Update src/jabs/ui/main_window/menu_handlers.py
gbeane Jan 6, 2026
b57680c
add update checker to JABS menu
gbeane Jan 7, 2026
e84dea5
change menu text
gbeane Jan 7, 2026
a40840d
add docs/drafts/ to gitignore
gbeane Jan 7, 2026
3ee7697
readme edit
gbeane Jan 7, 2026
42b2784
add DOCS_README.md file
gbeane Jan 9, 2026
a2ebf85
edit docstring
gbeane Jan 9, 2026
e63191c
change import
gbeane Jan 9, 2026
c732013
remove extra comments from init file
gbeane Jan 9, 2026
6ab0ca4
shortening menu documentation
gbeane Jan 9, 2026
4ae43b9
shortening menu documentation
gbeane Jan 9, 2026
bb9eeb5
edit some comments
gbeane Jan 9, 2026
79a66c3
fix unpickleable warm up function
gbeane Jan 9, 2026
ba3ee4f
run update check in a separate thread to hang the UI
gbeane Jan 9, 2026
20d8ca3
remove unused method in Project
gbeane Jan 9, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ data
tests/testing_notebook.ipynb
docs/notes.txt
docs/work_history.md
docs/drafts/
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ Choudhary, A., Geuther, B. Q., Sproule, T. J., Beane, G., Kohar, V., Trapszo, J.

JABS requires pose files generated from the Kumar Lab's mouse pose estimation neural networks. Single mouse pose files are generated from [this repository](https://github.com/KumarLabJax/deep-hrnet-mouse). Multi-mouse is still under development. Contact us for more information.

## Requirements

JABS was initially developed on Python 3.10. See the `pyproject.toml` for a list of required Python packages. These packages are available from the Python Package Index (PyPI).

Currently, JABS supports Python 3.10 through 3.14.

## Installation

This section describes how to install JABS as an end user. Developers should see the [JABS Development](#jabs-development) section below for instructions on setting up a development environment.
Expand Down Expand Up @@ -89,6 +83,7 @@ source jabs.venv/bin/activate
jabs.venv\Scripts\activate.bat
```

**JABS supports Python 3.10 through 3.14. Make sure to use a compatible Python version when creating the virtual environment.**

### Install from PyPI

Expand Down
8 changes: 8 additions & 0 deletions docs/DOCS_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# JABS Docs

This directory contains JABS documentation that is not included as part of the jabs-behavior-classifier package itself. Any files here are intended to be viewed directly, rather than imported as modules. If you have documentation that should be part of the in-app help system, please add them to src/jabs/resources/docs instead. The user guide in this directory mostly links to those in-app docs so there is a single source of truth for User Guide documentation.

## Documents in this directory:

- [DEVELOPMENT.md](DEVELOPMENT.md): Instructions for developers who want to contribute to JABS.
- [user-guide.md](user-guide.md): The JABS User Guide, mostly links to markdown files in src/jabs/resources/docs/user_guide.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"markdown2>=2.5.1,<3.0.0",
"numpy>=2.0.0,<3.0.0",
"opencv-python-headless>=4.8.1.78,<5.0.0",
"packaging>=24.0",
"pandas>=2.2.2,<3.0.0",
"pyside6>=6.8.0,!=6.9.0",
"scikit-learn>=1.5.0,<2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/jabs/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .scripts import main
from .scripts.gui_entrypoint import main

if __name__ == "__main__":
main()
136 changes: 58 additions & 78 deletions src/jabs/project/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import getpass
import gzip
import json
import os
import shutil
import sys
from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, as_completed
from concurrent.futures import as_completed
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

import h5py
import numpy as np
Expand All @@ -28,10 +28,8 @@
from .video_labels import VideoLabels
from .video_manager import VideoManager


def _warm_noop(n: int = 0) -> int:
"""Trivial picklable function used to force worker process spin-up."""
return n
if TYPE_CHECKING:
from jabs.utils.process_pool_manager import ProcessPoolManager


class Project:
Expand All @@ -43,27 +41,19 @@ class Project:
and retrieving project settings.

Executor pool:
Each Project owns a persistent **non-resizing** `concurrent.futures.ProcessPoolExecutor`
used for CPU-bound feature extraction (parallelized per video). The pool size is fixed
at construction time via ``executor_workers`` (defaults to ``os.cpu_count()`` if None).
The pool is created lazily on first use (e.g., when calling `get_labeled_features`) or
can be pre-spawned with `warm_executor(wait=True)` to avoid first-use latency.

- The pool **does not auto-resize**. If you need a different size, construct a new
`Project` with the desired ``executor_workers`` (a dedicated `resize_executor()` may
be added later).
- Submitting fewer jobs than workers is fine (extra workers idle). Submitting more jobs
than workers is also fine (tasks queue).
- Submissions are thread-safe; the pool can be used from worker/QThreads.
- Call `shutdown_executor()` on application exit for a clean teardown. A best-effort
shutdown is also attempted in `__del__`, but explicit shutdown is preferred.
The Project can optionally use a shared application-level `ProcessPoolManager` for CPU-bound
feature extraction (parallelized per video). If not provided (None), operations run single-threaded.

- When pool is provided, it's managed at the application level
- Submissions are thread-safe; the pool can be used from worker/QThreads
- The GUI passes a shared pool; CLI scripts typically run single-threaded (pool=None)

Args:
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_session_tracker: Whether to enable session tracking for this project.
executor_workers: Fixed size of the process pool; if None, uses CPU count.
validate_project_dir: Whether to validate the project directory structure on creation.

Properties:
Expand All @@ -83,10 +73,10 @@ class Project:
def __init__(
self,
project_path,
process_pool: "ProcessPoolManager | None" = None,
use_cache=True,
enable_video_check=True,
enable_session_tracker=True,
executor_workers: int | None = None,
validate_project_dir=True,
):
self._paths = ProjectPaths(Path(project_path), use_cache=use_cache)
Expand All @@ -106,52 +96,21 @@ def __init__(
if self._settings_manager.project_settings.get("defaults") != self.get_project_defaults():
self._settings_manager.save_project_file({"defaults": self.get_project_defaults()})

# Persistent, non-resizing process pool for feature extraction
self._executor: ProcessPoolExecutor | None = None
self._executor_size: int = max(1, (executor_workers or (os.cpu_count() or 1)))
# Shared application-level process pool for feature extraction
self._process_pool = process_pool

# Start a session tracker for this project.
# Since the session has a reference to the Project, the Project should be fully initialized before starting
# the session tracker.
self._session_tracker.start_session()

def _ensure_executor(self) -> ProcessPoolExecutor:
"""Create the persistent ProcessPoolExecutor once using the configured size."""
if self._executor is None:
self._executor = ProcessPoolExecutor(max_workers=self._executor_size)
return self._executor
def _ensure_executor(self) -> "ProcessPoolManager | None":
"""Return the shared application-level ProcessPoolManager, or None if running single-threaded."""
return self._process_pool

def shutdown_executor(self) -> None:
"""Shut down the persistent executor (call on app exit)."""
# We need to be defensive against partially constructed Project instances where __init__ may have
# raised an Exception before self._executor was declared and then shutdown_executor is called by __del__.
# In that case, the attribute may not exist, so we can't access the attribute directly here -- use
# getattr instead.
executor = getattr(self, "_executor", None)
if executor is not None:
with contextlib.suppress(Exception):
executor.shutdown(cancel_futures=False)
self._executor = None
self._executor_size = 0

def warm_executor(self, wait: bool = True) -> None:
"""Warm the project's process pool early (e.g., right after project load).

The pool size is fixed from `__init__` and does not resize here. See the class
docstring for details about the executor's lifecycle and guarantees.

Args:
wait: If True, submit trivial jobs so worker processes fully spawn before return.
"""
executor = self._ensure_executor()
if wait:
futures = [executor.submit(_warm_noop, i) for i in range(self._executor_size)]
for f in futures:
f.result()

def __del__(self):
# Best-effort shutdown of persistent executor
self.shutdown_executor()
"""No-op: executor is owned by the application, not individual projects."""
pass

def _validate_pose_files(self):
"""Ensure all videos have corresponding pose files."""
Expand Down Expand Up @@ -683,28 +642,49 @@ def get_labeled_features(
jobs.append(job)

executor = self._ensure_executor()
# create futures and map to video names
future_to_video = {
executor.submit(collect_labeled_features, job): job["video"] for job in jobs
}

results_by_video: dict[str, dict] = {}
for future in as_completed(future_to_video):
# check for early exit
if should_terminate_callable:
should_terminate_callable()

video_name = future_to_video[future]
try:
res = future.result()
except Exception as e:
raise RuntimeError(f"Feature collection failed for video: {video_name}") from e
if executor is not None:
# Parallel execution using the process pool
future_to_video = {
executor.submit(collect_labeled_features, job): job["video"] for job in jobs
}

for future in as_completed(future_to_video):
# check for early exit
if should_terminate_callable:
should_terminate_callable()

video_name = future_to_video[future]
try:
res = future.result()
except Exception as e:
raise RuntimeError(f"Feature collection failed for video: {video_name}") from e

# Stage results by video for deterministic finalization
results_by_video[video_name] = res

if progress_callable:
progress_callable() # once per video
else:
# Single-threaded execution
for job in jobs:
# check for early exit
if should_terminate_callable:
should_terminate_callable()

try:
res = collect_labeled_features(job)
except Exception as e:
raise RuntimeError(
f"Feature collection failed for video: {job['video']}"
) from e

# Stage results by video for deterministic finalization
results_by_video[video_name] = res
# Stage results by video for deterministic finalization
results_by_video[job["video"]] = res

if progress_callable:
progress_callable() # once per video
if progress_callable:
progress_callable() # once per video

# Deterministic finalize: append results in original 'videos' order
for video in videos:
Expand Down
2 changes: 0 additions & 2 deletions src/jabs/scripts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
"""jabs scripts module"""

__all__ = []
12 changes: 11 additions & 1 deletion src/jabs/ui/main_control_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,10 +556,20 @@ def remove_behavior(self, behavior: str):
self._get_first_label()

def _set_window_sizes(self, sizes: list[int]):
"""set the list of available window sizes"""
"""set the list of available window sizes

Args:
sizes: list of window sizes to set

Temporarily block signals to prevent currentIndexChanged from firing
when clear() sets the index to -1. When loading project settings, JABS should set
the window size, which will emit the signal appropriately.
"""
self._window_size.blockSignals(True)
self._window_size.clear()
for w in sizes:
self._window_size.addItem(str(w), userData=w)
self._window_size.blockSignals(False)

def _new_label(self):
"""callback for the "new behavior" button
Expand Down
Loading